Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
JavaScriptDevelopmentTutorial

How to cancel fetch requests in React with AbortController

Stale responses are a common React bug. Learn how to abort fetch calls in useEffect cleanups, add timeouts with AbortSignal.timeout() and AbortSignal.any(), cancel search requests and wrap it all in a reusable hook.

Cloudsoft Zone

Updated 9 min read

Visual Studio Code editor on Ubuntu showing the source files of a React JavaScript application
Photograph: Gátomo Dev / CC BY-SA 4.0, via Wikimedia Commons
In this article

A search box that fires a request on every keystroke, a profile page that refetches when the user ID in the URL changes, a dashboard that unmounts while a slow report is still loading: all three are ordinary React screens, and all three can show the wrong data if an old response arrives after a newer one. This is a race condition, and it is one of the most common bugs in client-side React code.

The browser already has a standard tool for this. AbortController lets you cancel a fetch() call, including the reading of its response body, and it has worked in all major browsers for years. React's own documentation says that if an Effect fetches something, its cleanup function "should either abort the fetch or ignore its result".

This tutorial shows how to do the aborting properly in a React 19 app: cancelling stale requests in useEffect, telling a user cancellation apart from a timeout, combining signals, and wrapping the pattern in a small reusable hook. The code uses only browser APIs and React, with no extra libraries.

What AbortController and AbortSignal do

An AbortController is a small object with two members. Its signal property returns an AbortSignal, which you pass to the operation you may want to cancel. Its abort(reason) method cancels that operation. According to MDN, abort() can cancel fetch requests, the consumption of response bodies and streams.

When you call abort() with no argument, the signal's reason becomes a DOMException named AbortError, and a pending fetch() promise rejects with it. If you pass your own reason, the promise rejects with that value instead. That detail matters later when you decide which errors to show to users.

AbortSignal also has three static helpers that are now widely supported:

APIWhat it doesFirst version (Chrome / Firefox / Safari)
AbortSignal.timeout(ms)Returns a signal that aborts itself after the given time, with a TimeoutError124 / 100 / 16
AbortSignal.any([signals])Returns a signal that aborts when any of the input signals aborts116 / 124 / 17.4
AbortSignal.abort(reason)Returns a signal that is already aborted93 / 88 / 15
signal.throwIfAborted()Throws the signal's reason if it has been aborted100 / 97 / 15.4

The version numbers come from MDN's browser compatibility data (release 8.1.4, 1 October 2026). If your audience includes older iOS devices, check the versions against your own analytics before relying on AbortSignal.any().

The bug: a stale response overwrites a fresh one

Here is a typical component that loads a user profile whenever userId changes:

JSX
import { useEffect, useState } from 'react';

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then(setUser);
  }, [userId]);

  return user ? <h1>{user.name}</h1> : <p>Loading…</p>;
}

If the user switches from profile 1 to profile 2, two requests are in flight. Nothing guarantees they finish in order. If the request for profile 1 is slower, it resolves last and the screen shows profile 1 under the URL for profile 2. The component also keeps downloading data nobody needs.

React's documentation offers an ignore flag as the minimum fix: the cleanup function sets ignore = true and the late response is discarded. That fixes the wrong-data bug, but the network request still runs to completion. Aborting fixes both.

Step 1: abort in the Effect cleanup

Create a controller inside the Effect, pass its signal to fetch(), and abort it in the cleanup function. React runs the cleanup before re-running the Effect with a new userId and when the component unmounts.

JSX
import { useEffect, useState } from 'react';

function Profile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

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

    async function load() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) {
          throw new Error(`Request failed with status ${res.status}`);
        }
        const data = await res.json();
        setUser(data);
      } catch (err) {
        if (err.name === 'AbortError') {
          return; // cancelled on purpose: not an error for the user
        }
        setError(err);
      }
    }

    load();

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

  if (error) return <p role="alert">Could not load the profile.</p>;
  return user ? <h1>{user.name}</h1> : <p>Loading…</p>;
}

Three points are worth noting:

  • The signal also covers res.json(). Aborting during body reading rejects the json() promise, so the late setUser call never happens.
  • An AbortError is expected, not a failure. Swallow it, otherwise every fast navigation would flash an error message.
  • fetch() does not reject on HTTP errors. A 404 or 500 still resolves, so check res.ok yourself.
Laptop screen showing colourful source code in a dark code editor on a desk
Aborting requests is a small change in component code that prevents stale data from reaching the screen.

Step 2: understand what Strict Mode shows you

In development, React's Strict Mode mounts each component, runs its Effects, cleans them up and runs them again. React's documentation says you will see two fetches in the Network tab during development and only one request in production. With the pattern above, the first request appears as "cancelled" in your browser's developer tools. That is the cleanup doing its job, not a bug to work around.

Do not remove Strict Mode or add a "has run" ref to suppress the second request. If the duplicate request in development bothers you, the React team's advice is to use a solution that deduplicates and caches requests, such as your framework's data loading or a client-side cache.

Step 3: add a timeout without losing manual cancellation

Slow networks are common, especially on mobile data. You may want a request to give up after, say, eight seconds. AbortSignal.timeout() does that, and AbortSignal.any() lets you keep the cleanup-based cancellation at the same time:

JSX
useEffect(() => {
  const controller = new AbortController();
  const signal = AbortSignal.any([
    controller.signal,
    AbortSignal.timeout(8000),
  ]);

  async function load() {
    try {
      const res = await fetch(`/api/reports/${reportId}`, { signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setReport(await res.json());
    } catch (err) {
      if (err.name === 'AbortError') return;      // unmounted or reportId changed
      if (err.name === 'TimeoutError') {
        setError('The report is taking too long. Please try again.');
        return;
      }
      setError('Could not load the report.');
    }
  }

  load();
  return () => controller.abort();
}, [reportId]);

MDN documents this distinction directly: a signal from AbortSignal.timeout() rejects with a TimeoutError, while a manual abort rejects with an AbortError. You can therefore show a helpful message for timeouts and stay silent for cancellations.

Step 4: cancel user-triggered requests

Effects are not the only place requests start. A search box often sends a request from an event handler. Keep the current controller in a ref and abort it before starting the next request:

JSX
import { useRef, useState } from 'react';

function Search() {
  const [results, setResults] = useState([]);
  const controllerRef = useRef(null);

  async function handleChange(event) {
    const query = event.target.value.trim();

    controllerRef.current?.abort();          // cancel the previous search
    if (query === '') {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    controllerRef.current = controller;

    try {
      const res = await fetch(
        `/api/search?q=${encodeURIComponent(query)}`,
        { signal: controller.signal },
      );
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setResults(await res.json());
    } catch (err) {
      if (err.name !== 'AbortError') console.error(err);
    }
  }

  return (
    <>
      <label htmlFor="q">Search</label>
      <input id="q" type="search" onChange={handleChange} />
      <ul>
        {results.map((r) => <li key={r.id}>{r.title}</li>)}
      </ul>
    </>
  );
}

Combine this with a short debounce if your API is expensive. Aborting stops the browser from processing old responses, but a request that has already reached the server may still be executed there, so cancellation is not a substitute for server-side rate limiting.

Also abort the last request when the component unmounts, for example with an Effect that only returns a cleanup: useEffect(() => () => controllerRef.current?.abort(), []).

Step 5: extract a reusable hook

Once the pattern is clear, move it into a hook so components do not repeat it. This version accepts a URL, returns data, error and loading, and handles both cancellation and timeouts:

JSX
import { useEffect, useState } from 'react';

export function useFetchJson(url, { timeoutMs = 10000 } = {}) {
  const [state, setState] = useState({ data: null, error: null, loading: true });

  useEffect(() => {
    if (!url) return;
    const controller = new AbortController();
    const signal = AbortSignal.any([
      controller.signal,
      AbortSignal.timeout(timeoutMs),
    ]);
    setState((s) => ({ ...s, error: null, loading: true }));

    fetch(url, { signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, error: null, loading: false }))
      .catch((err) => {
        if (err.name === 'AbortError') return;
        setState({ data: null, error: err, loading: false });
      });

    return () => controller.abort();
  }, [url, timeoutMs]);

  return state;
}

Usage stays short:

JSX
function Profile({ userId }) {
  const { data, error, loading } = useFetchJson(`/api/users/${userId}`);
  if (loading) return <p>Loading…</p>;
  if (error) return <p role="alert">Could not load the profile.</p>;
  return <h1>{data.name}</h1>;
}

Keep the hook small. When you need caching, retries, background refetching or request deduplication, React's documentation points to existing solutions such as TanStack Query, SWR or your framework's built-in data loading, which already accept or create abort signals for you.

Woman holding an iPhone and scrolling on its screen while sitting indoors
Slow mobile connections make timeouts and cancellation especially important for web apps.

Making your own async functions abortable

Signals are not limited to fetch(). Any long-running function in your code can accept one. The pattern MDN recommends is: check the signal at the start with throwIfAborted(), listen for the abort event, and remove the listener when the work finishes so long-lived signals do not leak memory.

JavaScript
export function wait(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted();

    const id = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);

    function onAbort() {
      clearTimeout(id);
      reject(signal.reason);
    }

    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

Pass the same signal down through helper functions, so one abort() call in the Effect cleanup stops the whole chain.

Checklist before you ship

  • Every Effect that calls fetch() creates an AbortController and aborts it in its cleanup.
  • AbortError is ignored; TimeoutError and HTTP errors produce a clear, accessible message (for example with role="alert").
  • res.ok is checked before reading the body.
  • A signal is never reused after it has aborted: create a new controller per request.
  • Event-handler requests abort the previous controller and the last one on unmount.
  • Strict Mode stays on; the double request in development is expected.
  • Server endpoints are still protected by their own timeouts and rate limits.

Summary

Race conditions in React data fetching come from responses that arrive after the component has moved on. Creating an AbortController per request and aborting it in the Effect cleanup removes both the wrong-data bug and the wasted download. AbortSignal.timeout() and AbortSignal.any() add timeouts without extra code, and the AbortError versus TimeoutError names tell you which situation you are in. For anything larger than a few screens, a data-fetching library or your framework's loaders will do this for you, but knowing the underlying pattern makes those tools easier to configure and debug.

Frequently asked questions

  • Do I still need the ignore flag if I use AbortController?

    No. Aborting the request in the Effect cleanup rejects both the fetch() call and any pending body read such as res.json(), so the late state update never runs. React's documentation accepts either approach; aborting also stops the unnecessary download.

  • Why do I see two requests, one cancelled, in development?

    React Strict Mode mounts, cleans up and re-runs Effects in development to expose missing cleanups. The first request is aborted by the cleanup. In production only one request is made.

  • How do I tell a timeout apart from a cancellation?

    Check the error's name. A signal created with AbortSignal.timeout() rejects with a TimeoutError, while calling controller.abort() without a reason rejects with an AbortError.

  • Does aborting a fetch stop the work on the server?

    Not necessarily. The browser stops waiting for and processing the response, but a request that already reached the server may still run there. Keep server-side timeouts and rate limits in place.

  • Can I reuse an AbortController for the next request?

    No. Once a signal is aborted it stays aborted, and any fetch using it rejects immediately. Create a new controller for each request.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. AbortController — MDN Web Docs
  2. AbortSignal — MDN Web Docs
  3. AbortController: abort() method — MDN Web Docs
  4. Synchronizing with Effects — React
  5. React Versions — React
  6. MDN browser-compat-data 8.1.4 (data.json) — MDN Web Docs, 2026-10-01

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.