---
title: "One Search Box, Eight Questions: AbortController Beyond fetch · Ihab Khattab"
description: "An interview question that starts with a search box showing the wrong results, and keeps going: timeouts, cancel buttons, event listeners, React, your own async functions, and Node.js. One API answers all of it."
url: "https://ihabkhattab.com/blog/abortcontroller-is-not-just-for-fetch/"
---

# One Search Box, Eight Questions: AbortController Beyond fetch

Oct 16, 2026 · 6 min read · Updated Oct 17, 2026

Picture a frontend interview. The interviewer shares a small search box and its code:

```js
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](https://ihabkhattab.com/blog/abortcontroller-is-not-just-for-fetch/).

_Without abort, every response is shown when it arrives, so the slowest one, “b”, wins. With abort, each keystroke cancels the request before it, and only “berlin” gets through._

_If you read the [debounce post](https://ihabkhattab.com/blog/debounce-vs-throttle/), 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.

```js
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.signal` to 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:

1. `signal.aborted` becomes `true`.
2. `signal.reason` is set. By default it’s a `DOMException` named `AbortError`. Pass your own with `controller.abort(new Error("User left the page"))`.
3. The signal fires an `abort` event. Everything listening, like `fetch`, 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:

```js
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:

```js
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:

```js
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:

```js
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?

```js
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:

```js
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:

```jsx
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.

```js
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`:

```js
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.

> `setTimeout` doesn’t take a signal. How does `sleep` stop?

You write it. Listen for `abort`, clear the timer, and reject with the signal’s reason:

```js
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()`** throws `signal.reason` if 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 a `TimeoutError`, a cancel stays an `AbortError`.
- **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:

```js
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));
}
```

_Measured in Chromium: 40 chunks of 25 ms, with Cancel clicked at 200 ms. Without a yield, the loop is one long task. With a yield, each chunk is its own task, and the click runs between two of them._

`await Promise.resolve()` doesn’t count as a turn. Microtasks run before the click does.

Thanks to [@thewidechaos](https://x.com/thewidechaos/status/2111351000540029124) 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`:

```js
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.race` only stops listening to it.
- Ignore `AbortError`, but handle `TimeoutError`: one is you, the other is a problem.
- `addEventListener(..., { signal })` removes any number of listeners with one `abort()`.
- To make your own function cancellable, accept a `signal` and pass it to every `await`.

[← All posts](https://ihabkhattab.com/blog/)
