Knowledge Base/guides/How to Cancel a Fetch Request with AbortController

How to Cancel a Fetch Request with AbortController

AbortController fetch cancellation is not just a way to stop awaiting a Promise. It tells the browser that the request should be aborted. That matters for timeouts, search-as-you-type UIs, navigation, and any interface where stale responses can overwrite newer state.

Ignoring an old response is sometimes enough. Cancelling the request is cleaner when the work should no longer continue.

Contents

The basic pattern

An AbortController creates an AbortSignal. Pass the signal into fetch, then call abort() when the request should stop.

const controller = new AbortController();

const request = fetch("/api/profile", {
  signal: controller.signal,
});

controller.abort();

await request;

When the request is aborted, the fetch Promise rejects with an abort error. In browser code, that error is usually a DOMException with the name "AbortError".

Real code normally wraps this in try/catch:

const controller = new AbortController();

try {
  const response = await fetch("/api/profile", {
    signal: controller.signal,
  });

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

  const profile = await response.json();
  console.log(profile);
} catch (error) {
  if (error instanceof DOMException && error.name === "AbortError") {
    console.log("Request was cancelled");
  } else {
    throw error;
  }
}

The signal is a one-way switch. Once a controller is aborted, its signal stays aborted. Create a new controller for each new request.

The naive timeout does not cancel fetch

A common timeout attempt uses Promise.race:

function timeout(ms: number) {
  return new Promise<never>((_, reject) => {
    setTimeout(() => {
      reject(new Error(`Timed out after ${ms}ms`));
    }, ms);
  });
}

async function loadReport() {
  return Promise.race([
    fetch("/api/report"),
    timeout(5000),
  ]);
}

This makes your code stop waiting after 5 seconds. It does not cancel the network request. The fetch can still be in flight after the timeout Promise rejects.

That distinction is the entire reason AbortController exists. Promise.race decides which result your code observes. AbortController tells the underlying operation to stop.

For the broader difference between race, any, all, and allSettled, see when to use Promise.all, Promise.allSettled, Promise.race, and Promise.any.

Build a fetch timeout with AbortController

Use a timer to call abort(), then clear the timer when the request finishes.

async function fetchWithTimeout(
  input: RequestInfo | URL,
  init: RequestInit = {},
  timeoutMs = 5000,
) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeoutMs);

  try {
    const response = await fetch(input, {
      ...init,
      signal: controller.signal,
    });

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

    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}

Usage:

const response = await fetchWithTimeout("/api/report", {}, 5000);
const report = await response.json();

The finally block matters. Without it, a completed request leaves a timer behind until it fires. That is small, but sloppy. Small sloppy things become production texture if nobody sweeps.

Handle AbortError separately

An aborted request is not the same kind of problem as a server error.

try {
  const response = await fetchWithTimeout("/api/report", {}, 5000);
  const report = await response.json();
  renderReport(report);
} catch (error) {
  if (error instanceof DOMException && error.name === "AbortError") {
    renderCancelledState();
  } else {
    renderFailureState();
  }
}

This split lets your UI avoid showing scary error messages for normal user actions.

Examples:

SituationGood UI response
User clicks CancelStop loading quietly or show "Cancelled"
User types a new search queryIgnore the aborted request
User navigates awayDo not update unmounted UI
Server returns 500Show a real error state

Also remember that HTTP errors are separate. A 404 response does not reject the fetch Promise by itself. The check for response.ok is still yours. The fetch 404 guide explains that boundary in detail.

Cancel stale search requests

Search boxes are where AbortController earns its keep. If the user types quickly, old requests can finish after newer ones and overwrite the UI with stale results.

Keep track of the active controller and abort it before starting the next request.

let activeSearchController: AbortController | null = null;

async function searchUsers(query: string) {
  activeSearchController?.abort();

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

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

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

    const users = await response.json();

    if (activeSearchController === controller) {
      renderUsers(users);
    }
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") {
      return;
    }

    renderSearchError();
  } finally {
    if (activeSearchController === controller) {
      activeSearchController = null;
    }
  }
}

The identity check looks fussy, but it protects the UI from old async work. If another search has started, the old request should not clear or update the active state.

In React, this same idea usually lives in an effect cleanup:

useEffect(() => {
  const controller = new AbortController();

  async function loadUser() {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });

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

      setUser(await response.json());
    } catch (error) {
      if (error instanceof DOMException && error.name === "AbortError") {
        return;
      }

      setError("Could not load user");
    }
  }

  loadUser();

  return () => {
    controller.abort();
  };
}, [userId]);

When userId changes or the component unmounts, the cleanup aborts the old request.

What cancellation does not do

AbortController is not a time machine. It does not undo server-side work that already happened.

If you abort a request that creates an invoice, sends an email, or charges a card, the server may still complete the operation. Cancellation means the client no longer wants the response and the browser should abort the request if it can. It is not a replacement for idempotency, transactions, or proper backend state checks.

AbortController also does not decide whether an HTTP response is successful. You still need:

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

Cancellation, status validation, and business logic are separate layers. Keep them separate and your async code becomes much less mysterious.

Key Takeaways

  • Create one AbortController per request or request group, then pass its signal to fetch.
  • Call abort() when the request is no longer useful, such as after a timeout, a route change, or a newer search query.
  • Treat "AbortError" as a normal cancellation path, not as the same thing as a server failure.
  • Promise.race can stop your code from waiting, but it does not cancel the underlying fetch.
  • Aborting a request does not undo server-side side effects that already occurred.
Share this article:
javascriptfetchasyncbrowser-apis