JavaScript provides native API for aborting asyncronous processes like web requests by exposing AbortController
and AbortSignal
interfaces.
Assume we have a search field on our site. Whenever user types in searh query, We send a request to server /api/v1/search?query=<entered query>
. We don’t want send requests on every keystroke so we use debounce function debounde(searchRequest, 300)
. It allows us to trigger search API only when user pauses typing at least for 300ms.
It’s good solution, but as we know the nature of asyncronous requests does not guarantee us the order of execution. It means that requests sent earlier can be fulfilled later. It could cause the problems to our search functionality if we leave the implementation logic naive.
As you can see from the image above we’ve sent two requests and they’ve come back in the wrong order. It means that we’ll show wrong search results to the user. To fix this situation we could use something like this:
let lastQuery = null;
const querySearchResults = async (query) => {
try {
lastQuery = query;
const res = await fetch(`/api/v1/search?query=${query}`);
const resJson = await res.json();
// checking if the query didn't not changed
if (lastQuery === query) {
// all good, the query didn't change
// while request was in progress
// display received results to user ...
} else {
// user has changed the query,
// the results are not relevant anymore
// discarding them, doing nothing
}
} catch (err) {
console.error("Error requesting search results", err);
// ...
}
};
querySearchResults("load");
querySearchResults(encodeURIComponent("load balancer"));
But it’s pretty clunky and a better way of handling it would be to abort the previous request when user sends a new one. That’s where AbortController
and AbortSignal
step in.
AbortController
To start lets create an instance of AbortController
.
const controller = new AbortController();
const { signal, abort } = controller;
AbortSignal
The instance exposes signal
property and abort()
method.
signal
allows us to subscribe to abort event
:
signal.addEventListener("abort", (event) => {
console.log(signal.aborted); // true
console.log(signal.reason); // new query
});
So whenever abort()
method is called the above event handler will be triggered. This method allows to pass a reason when called. It will be available in the event handler event as reason
property. It can be handy if you want to support and handle different cases of aborting:
abort("new query");
The nice thing is that JavaScript support many cancelable APIs our of the box. It means that we can pass controller’s signal
property say into fetch()
method and trigger signal.abort()
when user chnges search query:
const querySearchResults = (query, signal) => {
try {
const res = await fetch(
`/api/v1/search?query=${query}`,
{ signal }
)
// ...
} catch (err) {
if (error.name === 'AbortError') {
console.log('Request has been aborted')
} else {
console.error(err)
}
}
}
const controller1 = new AbortController()
querySearchResults('load', controller1.signal)
controller1.abort()
const controller2 = new AbortController()
const query = encodeURIComponent('load balancer')
querySearchResults(query, controller2.signal)
// ...
Assuming we have Search
component in react we could implement somethin like this:
const Search = () => {
const [query, setQuery] = useState()
useEffect(() => {
const controller = new AbortController()
const { signal } = controller
querySearchResults(query, signal)
return () => controller.abort()
}, [query])
return <>...<>
}
On top of that you can and should combine it with debounce()
to get the best user experience.
The same API is available in Node.js. It can be used with timers, fs.readFile
, fs.writeFile
, https.request
etc.