AbortController: the Swiss army knife of JavaScript
Most people know AbortController as the thing you use to cancel fetch requests. That’s fair enough — it was literally designed for that. But AbortController has quietly become one of the most versatile tools in the JavaScript standard library. Its signal mechanism plugs into event listeners, streams, Node.js APIs, and even your own custom code.
Let’s go through the ways you can use it, starting with the obvious one.
Aborting fetch requests
The classic use case. You create an AbortController, pass its signal to fetch, and call abort() when you want to cancel:
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then((response) => response.json())
.then((data) => console.log(data))
.catch((err) => {
if (err.name === 'AbortError') {
console.log('Fetch was cancelled');
} else {
throw err;
}
});
// Cancel the request
controller.abort();This is essential for any situation where a request might become irrelevant — navigating away from a page, a user typing in a search box (where each keystroke triggers a new request), or imposing a timeout.
Fetch with a timeout
AbortSignal.timeout() creates a signal that automatically aborts after a given number of milliseconds. No manual controller needed:
fetch('/api/slow-endpoint', {
signal: AbortSignal.timeout(5000),
})
.then((response) => response.json())
.catch((err) => {
if (err.name === 'TimeoutError') {
console.log('Request timed out after 5 seconds');
}
});Note that a timed-out signal throws a TimeoutError, not an AbortError. This lets you distinguish between explicit cancellations and timeouts.
Cleaning up event listeners
This is where AbortController starts to get interesting. You can pass a signal as an option to addEventListener, and when the signal aborts, the listener is automatically removed:
const controller = new AbortController();
element.addEventListener('click', handleClick, {
signal: controller.signal,
});
element.addEventListener('keydown', handleKeydown, {
signal: controller.signal,
});
window.addEventListener('resize', handleResize, {
signal: controller.signal,
});
// Remove all three listeners in one call
controller.abort();No more keeping track of every handler reference so you can call removeEventListener later. One abort() call tears down everything attached to that signal.
In React effects
This pattern is a perfect fit for React’s useEffect cleanup. Here’s a comparison.
Without AbortController, you need to hold a reference to every handler and remove them individually:
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
const handleScroll = () => {
setScrollY(window.scrollY);
};
const handleKeydown = (e) => {
if (e.key === 'Escape') {
setOpen(false);
}
};
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll);
document.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll);
document.removeEventListener('keydown', handleKeydown);
};
}, []);With AbortController, the cleanup collapses to a single line:
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
window.addEventListener(
'resize',
() => setWidth(window.innerWidth),
{ signal }
);
window.addEventListener(
'scroll',
() => setScrollY(window.scrollY),
{ signal }
);
document.addEventListener(
'keydown',
(e) => {
if (e.key === 'Escape') {
setOpen(false);
}
},
{ signal }
);
return () => controller.abort();
}, []);The signal-based version is shorter and harder to get wrong. You can’t accidentally forget to remove a listener — if it’s attached to the signal, it gets cleaned up.
This also works nicely for combined fetch + event listener cleanup in a single effect:
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
// Fetch data
fetch(`/api/items/${id}`, { signal })
.then((res) => res.json())
.then(setItems)
.catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
// Listen for live updates
window.addEventListener(
'focus',
() => {
fetch(`/api/items/${id}`, { signal })
.then((res) => res.json())
.then(setItems)
.catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
},
{ signal }
);
return () => controller.abort();
}, [id]);One controller, one cleanup line, and both the event listener and any in-flight fetches get cancelled together.
Composing signals with AbortSignal.any()
Sometimes you want to cancel an operation if any of several conditions are met. AbortSignal.any() combines multiple signals into one:
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(10000);
const combinedSignal = AbortSignal.any([
userController.signal,
timeoutSignal,
]);
fetch('/api/data', { signal: combinedSignal }).catch((err) => {
if (err.name === 'TimeoutError') {
console.log('Timed out');
} else if (err.name === 'AbortError') {
console.log('User cancelled');
}
});
// User can cancel manually at any time
cancelButton.onclick = () => userController.abort();This is especially handy in React for combining a component-level abort signal with a user-triggered cancellation or a timeout.
Cancelling streams
AbortController works with both readable and writable streams. If you’re consuming a streaming response, aborting the signal tears down the stream:
const controller = new AbortController();
const response = await fetch('/api/stream', {
signal: controller.signal,
});
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
processChunk(value);
}
} catch (err) {
if (err.name === 'AbortError') {
console.log('Stream cancelled');
}
}
// Call this from anywhere to stop the stream
controller.abort();This is the right way to handle “stop generating” buttons for streaming AI responses, or cancelling large file downloads midway through.
Using the signal in your own APIs
AbortSignal isn’t locked to built-in APIs. You can accept a signal in your own functions and respond to it:
function pollForUpdates(url, interval, { signal } = {}) {
return new Promise((resolve, reject) => {
const timer = setInterval(async () => {
try {
const res = await fetch(url, { signal });
const data = await res.json();
if (data.complete) {
clearInterval(timer);
resolve(data);
}
} catch (err) {
clearInterval(timer);
reject(err);
}
}, interval);
// Stop polling when the signal aborts
signal?.addEventListener('abort', () => {
clearInterval(timer);
reject(signal.reason);
});
});
}
// Usage
const controller = new AbortController();
pollForUpdates('/api/job/123/status', 2000, {
signal: controller.signal,
});
// Stop polling
controller.abort();By following the { signal } convention, your APIs compose naturally with AbortSignal.timeout(), AbortSignal.any(), and anything else that speaks the signal protocol.
In Node.js
Node.js has adopted AbortSignal across its core APIs. A few examples:
setTimeout / setInterval
const controller = new AbortController();
const timeout = setTimeout(() => {
console.log('This might never run');
}, 5000, { signal: controller.signal });
// Cancels the timer
controller.abort();Child processes
import { exec } from 'node:child_process';
const controller = new AbortController();
exec('long-running-command', { signal: controller.signal }, (err) => {
if (err?.name === 'AbortError') {
console.log('Process was killed');
}
});
// Kill the child process
controller.abort();fs.readFile and fs.writeFile
import { readFile } from 'node:fs/promises';
const controller = new AbortController();
readFile('/path/to/huge-file.csv', {
signal: controller.signal,
}).catch((err) => {
if (err.name === 'AbortError') {
console.log('File read cancelled');
}
});
controller.abort();Event emitters
import { on } from 'node:events';
const controller = new AbortController();
// Async iteration over events
for await (const [message] of on(chatSocket, 'message', {
signal: controller.signal,
})) {
console.log(message);
}
// Loop exits when controller.abort() is calledWrapping up
AbortController started life as a way to cancel fetch requests, but the underlying pattern — a signal that broadcasts “stop what you’re doing” — turns out to be useful everywhere. Event listeners, streams, timers, child processes, your own async functions… anywhere you need cooperative cancellation, AbortSignal is the standard way to do it.
The key takeaways:
{ signal }onaddEventListenerremoves listeners automatically on abort — use it in React effects.AbortSignal.timeout()creates a self-aborting signal after a delay.AbortSignal.any()lets you combine multiple abort conditions.- Accept a
signaloption in your own async functions to make them cancellable. - Node.js supports signals in
setTimeout,child_process,fs,events, and more.
If you’re still writing manual removeEventListener cleanup or rolling your own cancellation booleans, give AbortController a proper look. It’s already everywhere.