One Search Box, Eight Questions: AbortController Beyond fetch
Picture a frontend interview. The interviewer shares a small search box and its code:
input.addEventListener("input", async () => {
const response = await fetch(`/api/search?q=${encodeURIComponent(input.value)}`);
showResults(await response.json());
});
Type “berlin” quickly. Sometimes it shows the results for “b”. Why?
It looks like a warm-up question. It isn’t. Every follow-up after it is answered by the same small API, and most of us only know the first answer.
Question 1: Why Are the Results Wrong?#
Every keystroke sends a request: “b”, “be”, “ber”, up to “berlin”. Nothing says they come back in that order. A short query like “b” matches more results, so it can be the slowest. If it arrives last, it overwrites the right results.
Try it. Type “berlin” quickly, or let the button type it for you. The pretend server answers short queries more slowly:
Interactive demo: a search box with two result panels, one without abort and one with it. Both use the same pretend server. Try it on the page.
Without abort
Nothing yet
With abort
Nothing yet
If you read the debounce post, you might say “debounce it”. That helps: fewer requests, fewer chances to race. But if someone pauses mid-word, two requests can still be in flight, and the race is back.
The fix: when a new search starts, cancel the one that’s still running.
let controller;
input.addEventListener("input", async () => {
controller?.abort(); // Cancel the request that is still running, if any.
controller = new AbortController();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(input.value)}`, { signal: controller.signal });
showResults(await response.json());
} catch (error) {
if (error.name !== "AbortError") throw error;
}
});
Two objects do the work:
- The controller is the button. Only code that holds it can press it, with
controller.abort(). - The signal is the wire. You hand
controller.signalto anything that should stop when the button is pressed.
That split matters. You can pass the signal around freely: whoever gets it can listen, but they can’t cancel anything themselves.
Question 2: What Does abort() Actually Do?#
Walk me through what happens when you call
abort().
Three things:
signal.abortedbecomestrue.signal.reasonis set. By default it’s aDOMExceptionnamedAbortError. Pass your own withcontroller.abort(new Error("User left the page")).- The signal fires an
abortevent. Everything listening, likefetch, reacts to it.
fetch then rejects with signal.reason. That’s why the catch checks for AbortError: it’s the error you caused on purpose, so you ignore it.
The signal also covers the body. If the response headers have arrived but response.json() is still reading, aborting stops that too.
Abort is one-way and one-time. Calling abort() again does nothing, and there’s no “un-abort”. That’s why the fix makes a new controller for every search: new job, new controller. A fetch started with an aborted signal rejects right away.
Question 3: So the Server Stops Searching?#
When you abort, does the server stop too?
No. Aborting a fetch cancels the request on your side, and your code stops waiting. It doesn’t undo anything on the server. If the request already arrived, the server may run it to the end anyway.
I tested this with a small Node server that writes half a response, then the rest 300 ms later. The client aborted after 50 ms. The server saw the connection close, but its handler still ran to the end.
For a search, that’s fine. For POST /payments, aborting means “I stopped waiting”, not “it didn’t happen”. Don’t use abort as an undo button.
Question 4: Add a Timeout#
The search API is sometimes slow. Give up after 5 seconds.
The first idea is often a race between the fetch and a timer:
const response = await Promise.race([
fetch(url),
new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000)),
]);
And when the timer wins, what happens to the request?
Nothing. It’s still running. You just stopped listening to it.
AbortSignal.timeout() gives you a signal that aborts itself after a delay, and the request is really cancelled:
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
One catch: a timeout rejects with a TimeoutError, not an AbortError. That’s on purpose. An abort is usually you saying “never mind”, so you ignore it. A timeout is something going wrong, so you probably want to tell the user:
try {
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
return await response.json();
} catch (error) {
if (error.name === "TimeoutError") showError("The server is taking too long. Try again?");
else if (error.name !== "AbortError") throw error;
}
Question 5: A Cancel Button, Too#
Now a report that takes a while. Add a Cancel button, and keep a 10-second timeout.
A fetch takes only one signal. AbortSignal.any() combines several into one that aborts when the first of them does:
const cancel = new AbortController();
cancelButton.addEventListener("click", () => cancel.abort());
const signal = AbortSignal.any([cancel.signal, AbortSignal.timeout(10_000)]);
const response = await fetch("/api/report", { signal });
signal.reason tells you which one won: AbortError if the user cancelled, TimeoutError if time ran out.
Question 6: Clean Up These Listeners#
Different topic. This drag-and-drop code adds three listeners and removes them later. Can you make it shorter?
function onMove(event) { /* ... */ }
function onUp(event) { /* ... */ }
function onKey(event) { /* ... */ }
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("keydown", onKey);
// Later, one line per listener, with the exact same arguments:
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("keydown", onKey);
It sounds unrelated, but it’s the same answer. addEventListener takes a signal option. When the signal aborts, the listener is removed:
const controller = new AbortController();
const { signal } = controller;
window.addEventListener("pointermove", (event) => { /* ... */ }, { signal });
window.addEventListener("pointerup", (event) => { /* ... */ }, { signal });
window.addEventListener("keydown", (event) => { /* ... */ }, { signal });
// Later: removes all three.
controller.abort();
removeEventListener needs the exact same function you passed in, so the old version can’t use inline arrow functions. With a signal, inline functions are fine, and you can’t forget one.
Where would that help in React?
In a useEffect. One controller cancels the fetch and removes the listener when the component unmounts or userId changes:
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
fetch(`/api/users/${userId}`, { signal })
.then((response) => response.json())
.then(setUser)
.catch((error) => {
if (error.name !== "AbortError") setError(error);
});
window.addEventListener("resize", updateLayout, { signal });
return () => controller.abort();
}, [userId]);
It also fixes Question 1 again. If userId changes from 1 to 2 while user 1 is still loading, the old request is cancelled and can’t overwrite user 2.
In development, React’s Strict Mode runs every effect twice, so you’ll see the first request cancelled in the Network tab. That’s the cleanup working, not a bug.
Question 7: Make Your Own Function Cancellable#
This function polls an export job until it’s ready. Make it stop when the user closes the dialog.
async function waitForExport(id) {
while (true) {
const response = await fetch(`/api/exports/${id}`);
const { status } = await response.json();
if (status === "ready") return;
await sleep(2000);
}
}
Accept a signal, and pass it down to every await:
async function waitForExport(id, { signal } = {}) {
while (true) {
const response = await fetch(`/api/exports/${id}`, { signal });
const { status } = await response.json();
if (status === "ready") return;
await sleep(2000, { signal });
}
}
const controller = new AbortController();
closeButton.addEventListener("click", () => controller.abort());
await waitForExport(42, { signal: controller.signal });
Close the dialog and the loop stops, whether it’s waiting on the network or sleeping between polls.
setTimeoutdoesn’t take a signal. How doessleepstop?
You write it. Listen for abort, clear the timer, and reject with the signal’s reason:
function sleep(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
signal?.throwIfAborted(); // Already aborted? Don't even start.
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(signal.reason);
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
Three details here:
throwIfAborted()throwssignal.reasonif the signal is already aborted. A listener added to an aborted signal never fires, so without this check, the promise would wait the full time.reject(signal.reason)keeps the error name. A timeout stays aTimeoutError, a cancel stays anAbortError.- Removing the listener when the timer finishes matters if the signal lives long, like one per page. Otherwise, every call leaves a listener behind.
For a loop of synchronous work, like processing a big array, checking the signal isn’t enough. While the loop runs, the main thread is busy, so the Cancel click can’t even call abort() until the loop ends. Split the work into chunks, give the browser a turn between them, and check the signal after each turn:
async function processAll(items, { signal } = {}) {
for (let i = 0; i < items.length; i += 500) {
await yieldToMain(); // Let pending clicks (and timeouts) run.
signal?.throwIfAborted();
items.slice(i, i + 500).forEach(processItem);
}
}
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
await Promise.resolve() doesn’t count as a turn. Microtasks run before the click does.
Thanks to @thewidechaos for catching that an earlier version of this section skipped the yield.
Question 8: Does Any of This Work in Node.js?#
Last one. We run the same code on the server. Does it work there?
Yes. Node took the same API instead of inventing its own, so a lot of its built-in modules take a signal:
import { setTimeout as sleep } from "node:timers/promises";
import { readFile } from "node:fs/promises";
import { once } from "node:events";
const signal = AbortSignal.timeout(5000);
await sleep(1000, null, { signal }); // A cancellable sleep, built in.
await readFile("big.json", { signal }); // Stop reading a file.
await once(server, "listening", { signal }); // Stop waiting for an event.
So in Node, you don’t need the sleep from Question 7: timers/promises already has one. Child processes, streams, and readline take a signal too.
One difference to know: Node’s own APIs reject with an AbortError even when the signal timed out, and put the real reason in error.cause. fetch rejects with the reason directly. So a timeout from fetch has the name TimeoutError, but the same timeout in timers/promises has the name AbortError and error.cause.name === "TimeoutError".
Everything here works in all current browsers and in Node.js 18 and newer. AbortSignal.any() is the newest piece: it needs Node.js 18.17 or 20.3, and browsers have had it since early 2024.
The Debrief#
Eight questions, one API. Most of the answers are “pass it a signal”.
- The controller cancels, the signal listens. Pass the signal around, keep the controller.
- Abort is one-time. New job, new controller.
- Aborting stops your code from waiting. It doesn’t undo anything on the server.
AbortSignal.timeout()really cancels the request.Promise.raceonly stops listening to it.- Ignore
AbortError, but handleTimeoutError: one is you, the other is a problem. addEventListener(..., { signal })removes any number of listeners with oneabort().- To make your own function cancellable, accept a
signaland pass it to everyawait.