Ch. 1 · JavaScript

JavaScript Abort Signals and Request Ownership

Learn AbortController step by step: cancel fetch, prevent stale search results, combine cancellation with timeouts and handle errors correctly.

~9 min readintermediateupdated Oct 3, 2026

Imagine a candidate building a user-search box. The user types Ada, then quickly changes the query to Grace. Both requests start successfully, but the Ada request finishes last. If every completed request is allowed to update the screen, the search field says Grace while the results show Ada.

This is a race condition: completion order differs from the order in which the user expressed their intent. A useful solution must answer two questions:

  1. Can the old request stop doing unnecessary work?
  2. Even if it finishes, is it still allowed to change this screen?

AbortController helps with the first question. A request identity check answers the second. This guide builds both, explains each decision and shows what to say when an interviewer asks about the limitations.

Step 1: Understand controller versus signal

The controller is the object your application owns. Calling abort() changes its signal to an aborted state. You pass the signal to an API that supports cancellation.

const controller = new AbortController();
const signal = controller.signal;

console.log(signal.aborted); // false
controller.abort();
console.log(signal.aborted); // true
JavaScript

Think of the controller as the cancellation switch and the signal as the notification channel. The receiving operation must cooperate: a custom function that ignores the signal continues running.

One signal can be shared by several related operations. Aborting it requests cancellation of all cooperating operations using it. That is useful when they have one owner, such as a page that is being closed. Use separate controllers when operations need independent lifetimes.

An aborted signal cannot be reset. Create a fresh controller for a new search, rather than reusing the previous one.

Step 2: Attach the signal to fetch

Here is a small adapter for an application endpoint. It assumes the endpoint returns JSON containing a users array. The endpoint is illustrative; replace it with your own API.

async function fetchUsers(query, { signal }) {
  const response = await fetch(
    `/api/users?q=${encodeURIComponent(query)}`,
    { signal }
  );

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

  const body = await response.json();
  if (!Array.isArray(body.users)) {
    throw new Error('Search returned an invalid response');
  }
  return body.users;
}
JavaScript

There are three separate failure boundaries here:

  • The request can fail or be cancelled before a response is available.
  • An HTTP response can indicate failure. Fetch does not treat every 404 or 500 response as a rejected promise, so check response.ok.
  • Reading or interpreting the response body can fail. Cancellation can also occur after headers arrive while the body is still being consumed.

Checking that users is an array is only minimal demonstration validation. A production boundary should also validate each record’s required fields before using them.

Step 3: Give each search an identity

Cancelling the old request reduces work, but it is not the entire UI rule. An asynchronous adapter may ignore cancellation, or your pipeline may include additional processing that does not observe the signal.

The following runner accepts an adapter and a state callback. It has no framework dependency and can be tested without a browser or a live endpoint.

function createSearchRunner(findUsers, onState) {
  let version = 0;
  let activeController = null;

  async function search(rawQuery) {
    const myVersion = ++version;
    activeController?.abort();
    activeController = null;

    const query = rawQuery.trim();
    if (!query) {
      onState({ status: 'idle', query, users: [] });
      return;
    }

    const controller = new AbortController();
    activeController = controller;
    onState({ status: 'loading', query, users: [] });

    try {
      const users = await findUsers(query, {
        signal: controller.signal
      });

      if (myVersion !== version) return;
      // A non-cooperating adapter may resolve after cancellation.
      if (controller.signal.aborted) {
        onState({ status: 'cancelled', query, users: [] });
        return;
      }
      onState({ status: 'success', query, users });
    } catch (error) {
      if (myVersion !== version) return;
      if (controller.signal.aborted) {
        onState({ status: 'cancelled', query, users: [] });
        return;
      }
      onState({
        status: 'error', query, users: [],
        message: error instanceof Error
          ? error.message : 'Search failed'
      });
    } finally {
      if (myVersion === version) activeController = null;
    }
  }

  return {
    search,
    cancel() { activeController?.abort(); },
    dispose() {
      ++version;
      activeController?.abort();
      activeController = null;
    }
  };
}
JavaScript

Why increment before cancelling?

Incrementing version immediately revokes the old request’s permission to update the screen. Its rejection or completion can then be ignored even if it happens while the replacement is starting.

Why capture myVersion?

Each call remembers the version it owns. Comparing that captured number with the current version tells us whether it still represents the user’s latest intent. Comparing query text alone is weaker: the user could search Ada, then Grace, then Ada again. Those are three distinct operations despite repeated text.

Why check both version and aborted?

An obsolete operation should produce no UI update. A deliberately cancelled current operation can produce a cancelled state. The second check also handles an adapter that resolves successfully despite its signal having been aborted.

Why guard finally?

An old request must not clear the replacement request’s controller. The same principle applies to loading flags: an unconditional old finally can hide the new request’s loading indicator.

dispose() revokes all current updates and requests cancellation. Call it when the owning view is removed. It prevents later completions from writing into a view that no longer owns the work.

Worked scenario

Run this after the runner above in a recent JavaScript environment. This fake adapter deliberately ignores cancellation, making the identity guard’s behavior visible without relying on real network timing.

const wait = milliseconds =>
  new Promise(resolve => setTimeout(resolve, milliseconds));

async function demoFindUsers(query, { signal }) {
  // Deliberately ignore signal to demonstrate the ownership guard.
  await wait(query === 'Ada' ? 80 : 10);
  return [{ id: query.toLowerCase(), name: query }];
}

const states = [];
const runner = createSearchRunner(demoFindUsers, state => {
  states.push(state);
  console.log(state.status, state.query);
});

await Promise.all([
  runner.search('Ada'),
  runner.search('Grace')
]);

console.log(states.at(-1).users[0].name); // Grace
runner.dispose();
JavaScript

The state sequence is:

loading Ada
loading Grace
success Grace
Grace
Text

Ada still finishes its fake work, but cannot replace Grace. That demonstrates why ignoring stale results and cancelling work are related but distinct protections.

To connect the runner to the real endpoint, use createSearchRunner(fetchUsers, renderState). An input handler calls runner.search(input.value), a Cancel button calls runner.cancel(), and the view’s teardown removes its handlers and calls runner.dispose().

Debouncing can reduce how often a search starts. It does not replace these protections: requests started before the debounce change may still overlap.

Step 4: Combine user cancellation with a timeout

A request may stop because the user cancels or because it exceeds its allowed duration. On supported platforms, compose those causes with AbortSignal.any() and AbortSignal.timeout().

async function loadWithDeadline(url, userSignal) {
  const deadline = AbortSignal.timeout(5000);
  const combined = AbortSignal.any([userSignal, deadline]);

  try {
    const response = await fetch(url, { signal: combined });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    if (combined.aborted) {
      // Inspect the winning reason, rather than only one input signal.
      if (combined.reason?.name === 'TimeoutError') {
        throw new Error('The request exceeded its time allowance');
      }
      throw combined.reason;
    }
    throw error;
  }
}
JavaScript

The combined signal adopts the first abort reason. A later cancellation must not be mistaken for the earlier timeout or vice versa. A caller can also abort with a custom reason, so not every cancellation reason is necessarily an Error with the name AbortError.

Check support in the browsers or runtime you target. If these helpers are unavailable, a controller with a timer is an alternative: the timer aborts the controller, and cleanup clears the timer and any installed listeners. Keep the same request-ownership guard.

The timeout helper uses active-time semantics in supporting browsers. It is not a server-side transaction deadline or a guarantee that a server has stopped processing the request.

Common mistake

Reusing an aborted controller

Once aborted, a signal remains aborted. Passing it to a new fetch does not create a new cancellation lifetime. Allocate one controller for each new independent operation.

Showing every cancellation as an error

Replacing a search is expected behavior. Avoid displaying a red error for an obsolete operation. Distinguish deliberate cancellation, a timed-out current request and a genuine failure that the user needs to act on.

Assuming abort stops arbitrary promises

A Promise constructor or custom asynchronous function is not automatically linked to a signal. Your API must check an already-aborted signal, register appropriate cancellation handling and release its resources. A request identity guard remains valuable even with cooperative cancellation.

Updating shared loading state from old work

A previous operation can finish after a new one has started. Only the current owner should set the current operation’s loading, error and success states. Keep those states associated with the operation rather than a free-floating boolean.

Step 5: Understand why cancellation cannot undo a write

A fetch client and a server do not share one transaction. Consider a payment request:

  1. The browser sends a request to create a payment.
  2. The server authorizes and commits the payment.
  3. The browser aborts before receiving the response.
  4. The UI sees cancellation, but the payment may already exist.

Cancellation therefore means the client stopped waiting or consuming data; it does not establish the server’s final business outcome.

For retryable writes, use a server-supported idempotency key representing one logical operation. Reuse that identity when retrying the same operation and retrieve its status when the outcome is uncertain. The server must enforce the contract; adding an arbitrary header without server support does not prevent duplicates.

This is a general ownership principle. Cancelling a request is different from compensating a completed payment, cancelling a dispatched shipment or rolling back a database transaction.

Interview exercise

Exercise 1: Find the stale-results bug

A search starts A, then B. B returns first. A returns later and changes the displayed results. Where must the correctness check happen?

Answer and reasoning

Place the ownership check immediately before every state update associated with the result or error. Starting a newer request must revoke the old request’s ownership. Request-scoped cancellation reduces work; the identity guard protects the visible state even when cancellation is not supported.

Exercise 2: Diagnose a disappearing spinner

Both requests execute finally(() => setLoading(false)). A ends while B is still pending. What goes wrong?

A clears B’s loading indicator because the flag is not owned by a request. Guard cleanup with current request identity, or represent loading as part of a request-scoped result object.

Exercise 3: Explain an uncertain payment

The user cancels a payment request, then clicks Pay again. Can the application assume the first payment never happened?

No. Retrieve the status of the original logical operation or safely retry using the same server-enforced idempotency identity. Creating a fresh operation can charge twice.

A concise model answer

“AbortController requests cancellation through a signal passed to a cooperating API such as fetch. I create a controller for each operation and abort obsolete work. I also track request identity so a late response or cleanup cannot overwrite the current UI. I handle cancellation separately from HTTP and network errors. For mutations, abort does not undo the server’s work, so retries need a server-side idempotency and outcome-checking contract.”

How to verify the implementation

Test behavior, not only whether abort() was called:

  • Resolve B before A and confirm B remains the final result, including when A ignores cancellation.
  • Cancel the current request and confirm it does not become a successful result.
  • Clear the query while a request is pending and confirm late data cannot leave the idle state.
  • Dispose the runner and confirm no subsequent callback changes the view.
  • Fail the current request and confirm the error is visible; fail an obsolete request and confirm it is ignored.
  • Check a 404 response, malformed JSON and timeout behavior separately when testing the fetch adapter.

The local demo verifies orchestration. It does not prove that a real endpoint, browser network stack or payment provider has been tested.

Continue learning

Connect this example with React request-race cleanup, JavaScript async loops and JavaScript interview questions.

For API definitions and compatibility, see MDN AbortController, MDN AbortSignal and Using the Fetch API. The search runner and scenarios above are original teaching examples rather than copied reference examples.

More in JavaScript

read ✓JavaScript · hard

JavaScript Async Iteration and Backpressure

Consume async generators with for await, propagate errors and pace a producer to a slow consumer without buffering the whole stream.

~3 min readread →
read ✓JavaScript · mid

JavaScript Async Loops and Concurrency

JavaScript Async Loops and Concurrency. Learn the reasoning, a practical example, common mistakes and an interview exercise.

~2 min readread →
esc