Managing Asynchronous Operations with AbortController
AbortController
is a niche feature. More of us work with things like Webpack configs, file I/O or buffers before stumbling upon this rather useful API. While not widely utilized, the AbortController
makes itself useful by providing a way to cancel asynchronous operations which you will shortly see may be… anything!
But first, let’s start with the basics.
Network request
The most well-known usage of the AbortController
is to use it to cancel network calls. The fetch
API supports the signal
property out of the box as an option, with the syntax as follows:
const controller = new AbortController();
const signal = controller.signal;
await fetch(url, { signal })
// To cancel the request, call:
controller.cancel()
In the Web Application framework world, you will likely find it used as shown below (throughout the article, I use React for code examples):
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchData = async () => {
try {
const res = await fetch(url, { signal })
const data = await res.json();
setState(data);
} catch (err) {
// handle error
}
}
void fetchData();
return () => {
controller.abort()
}
}, []);
This is necessary to ensure your component will not continue to fetch the API and then set its state when it is unmounted (aka bleed).
Timer
A typical sleep function looks like below:
const sleep = async (ms: number) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
It works great, but in this shape, it doesn’t let you cancel the callback. Let’s fix it:
const sleep = async (ms: number, timeoutId: NodeJS.Timeout) => {
return new Promise((resolve) => {
timeoutId = setTimeout(resolve, ms);
});
};
This works just fine for simple workflows, however, it has 2 (or 3 depending on your sensitivity) issues. Firstly, it will be flagged in most linting configurations as timeoutId
is not used. Secondly, when mixed with network requests, you end up with 2 different cleanup APIs. The third issue is that timeoutId
parameter is passed as a reference, and working with references in a language without strict control over them often leads to unforeseen bugs.
Using AbortController
can help us with it, especially because AbortController
signals can be connected to multiple sources.
Let’s modify our sleep function to add support for signals:
const sleep = (ms: number, { signal = null }: { signal: AbortSignal | null }): Promise<void> =>
new Promise((resolve) => {
const timeoutId = setTimeout(resolve, ms);
if (signal) {
signal.addEventListener('abort', () => clearTimeout(timeoutId), { once: true });
}
});
Now, as it is aligned with fetch
API, let’s see how it works in practice:
useEffect(() => {
const abortController = new AbortController();
const signal = abortController.signal;
const fetcher = async () => {
await fetch('/api/1', { signal });
await sleep(200, { signal });
await fetch('/api/2', { signal });
};
void fetcher();
return () => {
abortController.abort();
};
}, []);
As you can see, the AbortController
signal is utilised with both fetch
requests and our custom sleep
function, showcasing how a single signal can be shared across multiple asynchronous operations and keep your code clean.
Debounce and Throttle
Let’s say you have a search input where you want to fetch suggestions from an API as the user types. Firing off a request for every keystroke is rarely what you want as it has several performance and privacy implications. Instead, we debounce the API call, delaying it until the user stops typing for a brief moment. Let’s see how AbortController
helps with that:
const abortControllerRef = useRef<AbortController | null>(null);
const onInput = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
abortControllerRef.current?.abort(); // Safely abort previous async action
const input = event.currentTarget;
if (input.value?.length < 2) {
return; // Early return for short input values
}
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
await sleep(300, { signal }); // Debounce delay
await fetchSearchSuggestions(input.value, { signal });
},
[]
);
useEffect(() => {
return () => {
abortControllerRef.current?.abort(); // Abort any ongoing async actons on component unmount
};
}, []);
return (
<input onInput={onInput} />
);
Again, by utilising AbortController
, we make sure our code will handle interruptions with a breeze without managing multiple cleanups. The added benefit of using AbortController
for debounce
purposes and API calls are that it will cancel in-flight calls if the user starts typing after the sleep function is finished, ensuring the user will always receive the result for the latest query, not the query which resolved last!
Thank you to Trys Mudford for reviewing this article 👏
About the author
I’m Szymon, a Web and Design System Developer at Motorway. Since I joined over 5 years ago, I have been helping to build the fastest-growing used car marketplace in the UK.
In Plain English 🚀
Thank you for being a part of the In Plain English community! Before you go:
- Be sure to clap and follow the writer ️👏️️
- Follow us: X | LinkedIn | YouTube | Discord | Newsletter
- Visit our other platforms: Stackademic | CoFeed | Venture | Cubed
- Tired of blogging platforms that force you to deal with algorithmic content? Try Differ
- More content at PlainEnglish.io