DEV Community

Pop Watch
Pop Watch

Posted on

Stop Letting Stale Requests Win: A Practical AbortController Pattern

Modern interfaces make requests constantly: type-ahead search, filters, route changes, live validation, refresh buttons. The hard part is not starting a request. It is deciding which result is still allowed to change the UI.

A familiar bug looks like this:

  1. A user types ca, then quickly types cat.
  2. The request for cat returns first and renders the correct results.
  3. The older ca request returns later and overwrites them.

That is a correctness problem, not merely a performance problem. A loading spinner can be accurate while the screen is wrong.

The platform answer is AbortController. It gives an operation an AbortSignal; APIs that support the signal can stop their work when the controller is aborted. Fetch accepts a signal, and the DOM Standard also defines signals as a general cancellation mechanism—not a fetch-only feature.

The rule: one controller per current intent

Treat a controller as belonging to one user intent. When intent changes, abort the old controller before creating the next one. Do not share one controller across unrelated operations: once aborted, its signal stays aborted.

Here is a small search component with no framework assumptions:

const input = document.querySelector('#search');
const results = document.querySelector('#results');
let currentController;

input.addEventListener('input', async (event) => {
  const query = event.target.value.trim();

  // The new input makes the previous result obsolete.
  currentController?.abort();

  if (!query) {
    results.replaceChildren();
    return;
  }

  const controller = new AbortController();
  currentController = controller;

  try {
    const response = await fetch(
      `/api/search?q=${encodeURIComponent(query)}`,
      { signal: controller.signal }
    );

    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const items = await response.json();

    // A defensive final check: only the current intent may render.
    if (currentController !== controller) return;

    results.replaceChildren(
      ...items.map((item) => {
        const li = document.createElement('li');
        li.textContent = item.name;
        return li;
      })
    );
  } catch (error) {
    if (controller.signal.aborted) return;
    console.error('Search failed', error);
  }
});
Enter fullscreen mode Exit fullscreen mode

Two details make this dependable.

First, cancellation is not an error state for the user. The catch block deliberately ignores the request that this component chose to supersede. Log and present actual failures separately.

Second, cancellation is cooperative. Calling abort() tells a participating API to stop; it does not revoke bytes that may already have reached a server, undo a completed mutation, or make a non-signal-aware library stop. That is why this pattern is ideal for read requests and UI work, but it is not a substitute for server-side idempotency or authorization.

Add a deadline without tangled timers

A request can become obsolete because the user changed their mind, or because it has taken too long. Where supported, AbortSignal.timeout(ms) provides the latter. Combine the two reasons with AbortSignal.any():

async function loadProfile(userId, userSignal) {
  const deadline = AbortSignal.timeout(8_000);
  const signal = AbortSignal.any([userSignal, deadline]);

  const response = await fetch(`/api/users/${encodeURIComponent(userId)}`, {
    signal,
    headers: { Accept: 'application/json' },
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

The caller still owns the controller for “the user navigated away”; the function owns its eight-second service expectation. That separation makes cleanup easier to reason about.

Do not write timeout code that races fetch() against a promise and then forgets the underlying fetch. A promise race decides what your await observes; it does not automatically stop network work. A signal passed to fetch expresses cancellation to the fetch operation itself.

Use signals beyond fetch

Signals also help with event listener cleanup. The DOM Standard permits an AbortSignal in listener options. A modal or temporary view can register several listeners with one signal, then remove them as a group:

function mountDialog(dialog) {
  const controller = new AbortController();

  document.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') dialog.close();
  }, { signal: controller.signal });

  dialog.addEventListener('close', () => controller.abort(), {
    once: true,
  });

  return () => controller.abort();
}
Enter fullscreen mode Exit fullscreen mode

This is especially useful when a component has a clear lifetime but its listeners live on document, window, or another long-lived target.

Boundaries that cancellation does not solve

Cancellation is easy to over-credit. Keep these boundaries explicit:

  • It does not secure an endpoint. The server must authenticate and authorize every request even if the UI cancels it.
  • It does not roll back writes. For create, payment, or update operations, design server-side idempotency and explicit user-visible state.
  • It does not guarantee a server saw nothing. An abort can occur after request transmission has begun.
  • It does not replace input validation, output encoding, or rate limiting.
  • It does not make every API cancelable. Check an API’s documentation before assuming it accepts signal.

For accessibility, avoid announcing an aborted background request as a failure. Keep a visible loading state tied to the active controller, and preserve keyboard focus when replacing results. For privacy, do not put sensitive text in query strings merely because a request is short-lived; cancellation does not erase URL logs, browser history, or intermediary records.

Test the behaviour, not the timing

You do not need a slow production API to test this pattern. In an integration test, make two controlled promises for the transport layer. Resolve the second one first, then resolve the first one. The assertion is that only the second result reaches the renderer. Separately assert that replacing an active intent calls abort(), and that an aborted path does not show an error toast.

Avoid tests that say “wait 500 ms and hope the order reverses.” They are timing tests, so they become flaky under load. Control completion order instead. If your application wraps fetch, inject that wrapper in the test and capture the signal argument. You can assert signal.aborted after a new search begins without depending on a real network.

Also test cleanup on navigation or component unmount. A page may no longer be visible when a response settles; the old task must not mutate a detached view or announce stale content to assistive technology. This is a lifecycle contract, not a micro-optimization.

A short review checklist

Before shipping a cancellation path, ask:

  1. What user intent owns this controller?
  2. Which newer action makes it obsolete?
  3. Is an abort quiet while a real network or HTTP failure remains observable?
  4. Can an old response still render after parsing?
  5. Are writes protected by server-side idempotency rather than client cancellation?
  6. Does the target browser support the signal helpers you use, or is there a documented fallback?

The useful mental model is simple: a request is not “the latest” because it started last. It is latest only while the intent that created it is still current. AbortController lets your code model that fact directly.

Sources

Top comments (0)