Managing Asynchronous Operations with AbortController

Szymon Pajka
JavaScript in Plain English
4 min readMay 14, 2024

--

Screenshot of the code editor, with single line of code: “abortController.abort()”
Generated using ray.so

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:

--

--