Back to Networking
Networking
medium
mid

How do you cancel previous API requests when newer ones supersede them?

AbortController is the standard mechanism: create a controller, pass controller.signal to fetch, call controller.abort() to cancel. In React, store the controller in a ref or use the effect cleanup. Abort fires AbortError on the catch. For race protection (responses out of order), combine abort with a request-id guard. React Query / SWR handle this automatically when the query key changes.

7 min read·~10 min to think through

Cancellation prevents stale responses from clobbering newer ones and frees up network/CPU resources.

AbortController — the primitive

js
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
  .then(r => r.json())
  .then(setData)
  .catch(err => {
    if (err.name === 'AbortError') return;  // expected — user cancelled
    throw err;
  });

// Later: cancel
controller.abort();

When .abort() fires, the in-flight fetch rejects with AbortError. The browser tears down the connection (or in HTTP/2, closes the stream); the body never finishes reading.

Pattern 1: cancel previous on new (effect-based)

When fetching driven by an effect dependency (e.g. query prop):

jsx
useEffect(() => {
  const ctrl = new AbortController();
  fetch(`/search?q=${query}`, { signal: ctrl.signal })
    .then(r => r.json())
    .then(setResults)
    .catch(err => { if (err.name !== 'AbortError') setError(err); });

  return () => ctrl.abort();   // cleanup runs before next effect — cancels old
}, [query]);

Effect cleanup runs before the next effect fires (and before unmount), so changing query always cancels the previous request.

Pattern 2: cancel previous on click (event-based)

When the trigger is an event handler, store the controller in a ref:

jsx
const ctrlRef = useRef(null);

async function onClick() {
  ctrlRef.current?.abort();
  ctrlRef.current = new AbortController();
  try {
    const res = await fetch('/api/data', { signal: ctrlRef.current.signal });
    setData(await res.json());
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

Pattern 3: request-id guard (defense in depth)

Cancel might race — abort fires but response was already in transit. Add an id check:

jsx
const idRef = useRef(0);

async function fetchLatest() {
  const id = ++idRef.current;
  const res = await fetch('/api/data');
  if (id !== idRef.current) return;   // stale, ignore
  setData(await res.json());
}

Combine with abort for both:

jsx
async function fetchLatest() {
  ctrlRef.current?.abort();
  ctrlRef.current = new AbortController();
  const id = ++idRef.current;
  try {
    const res = await fetch('/api/data', { signal: ctrlRef.current.signal });
    if (id !== idRef.current) return;
    setData(await res.json());
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

Pattern 4: timeout + cancel together

js
const signal = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(5000),
]);
fetch(url, { signal });

Modern (Chrome 116+, Node 20+). One signal aborted by either user or timeout.

In React Query / SWR

Don't write any of the above. The library does it:

jsx
const { data } = useQuery({
  queryKey: ['search', query],
  queryFn: ({ signal }) => fetch(`/search?q=${query}`, { signal }).then(r => r.json()),
});

When query changes, the library aborts the previous query, dedupes concurrent identical queries, and discards stale responses.

What gets cancelled

  • Network request: connection torn down; server may eventually see RST but typically keeps processing (server-side cancellation is rare in HTTP).
  • Body read: if you've already received headers and are streaming the body, abort cancels the stream.
  • Pending response parse: res.json() is its own async — aborting before it finishes leaves the parse to fail with AbortError.

What does NOT cancel

  • Server-side work. The server keeps doing whatever it's doing — you've cancelled the client read, not the work.
  • Side effects of mutations. If you POSTed and the server already wrote to DB, abort doesn't roll that back.
  • Other in-flight requests sharing the connection (HTTP/1 yes, HTTP/2 multiplexed no).

Pitfalls

  • Don't auto-retry abort errors — the user (or app) intentionally cancelled.
  • Don't show error UI for AbortError — silent or no-op is correct.
  • POST cancellation is dangerous — the server may have committed. Treat POST cancel as "best effort"; design for idempotency.
  • Forgetting cleanup in event handlers — controller refs that aren't cleared accumulate listeners.
  • Reading body after abort.json() may reject with a different error mid-parse.

Mental model

Cancellation is for race control and resource hygiene, not for stopping side effects. The server doesn't care that you cancelled. Design your UI assuming the server may have done the work anyway, especially for mutations.

Follow-up questions

  • How does cancellation work server-side?
  • What's AbortSignal.any and when is it useful?
  • How does React Query handle cancellation automatically?
  • Why is cancelling a POST dangerous?

Common mistakes

  • Treating AbortError as a real error and surfacing it in UI.
  • Forgetting to abort on unmount — stale setState warnings.
  • Auto-retrying on AbortError — user/app cancelled intentionally.
  • Cancelling a POST and assuming the server didn't process it.
  • Storing controller in component state — re-renders bust the reference.
  • Not handling the race window (response arrives before abort fires).

Performance considerations

  • Cancellation saves bandwidth (server may stop sending body), browser parse cost, and React re-render churn. For search-as-you-type at scale, cancel + debounce together cut request rate ~80%. Server-side, request cancellation reduces wasted work only if the server checks the connection (rare in most stacks).

Edge cases

  • Pre-aborted signals: AbortController with abort() called before fetch — fetch rejects immediately.
  • Cross-realm signals (iframe/worker) — propagate via structured cloning.
  • Streaming responses (SSE, ReadableStream) — abort cancels the stream mid-read.
  • Service workers can intercept and serve cached responses; abort cancels the cache lookup too.
  • AbortSignal.any not yet on older browsers — feature-detect and fall back.

Real-world examples

  • GitHub omnisearch debounces input and cancels stale requests.
  • Algolia/Typesense client libraries cancel in-flight queries on next keystroke.
  • React Query, SWR, Apollo all handle cancellation internally.

Senior engineer discussion

Seniors should use AbortController as the default, distinguish AbortError from real errors, and articulate the server-side asymmetry (cancel is client-only). They reach for React Query for app-wide consistency and reserve hand-rolled abort patterns for edge cases.

Related questions