DEV Community

Cover image for Your React Fetch Requests Don't Care That You Left
Thea
Thea

Posted on Edited on

Your React Fetch Requests Don't Care That You Left

The hidden API costs of orphaned LLM streams

I was building a search feature: nothing fancy, just a text input that fetches results from an API as the user types.

It worked fine during testing, but then I noticed something strange. Stale results flashed on screen, and sometimes the UI showed data that didn't match what the user had typed last.
No error. No warning. Just something quietly wrong beneath the surface.

After some digging, I realized the problem wasn't really in my search logic. It was in my assumptions about how React and fetch work together.

This post is what I wish someone had explained to me before I ran into it.

The assumption that breaks everything

When a React component disappears from the screen, React says it has unmounted. And it's natural to assume that everything inside it stops, too. But that's not how JavaScript works.

If you start a fetch() request inside a component, that request doesn't automatically stop when the component unmounts. It continues independently of the component's lifecycle unless you cancel it or the operation finishes on its own.

And when the request finishes, its promise callbacks can still run. React won't bring an unmounted component back to life just because a response arrived, but the work may still have been unnecessary.

The bigger problem happens when the component is still mounted but the data it requested is no longer relevant.

Here's the sequence that causes trouble:

  1. A component starts a fetch request.
  2. The user types something new, or navigates to another view.
  3. React runs the previous effect's cleanup, if there is one.
  4. The old request finishes anyway if it wasn't cancelled.
  5. Its result may overwrite data from a newer request if the code doesn't guard against stale results.

The result: wasted work, and sometimes a UI showing results for a query the user already changed.

Older React versions also warned about state updates on unmounted components. React 18 removed that warning because it often flagged harmless cases, not because every asynchronous operation suddenly started cleaning itself up.

So let's look at what we can actually do about it.

The first fix: ignore obsolete results

The React docs show a simple pattern with an ignore flag:

import { useEffect, useState } from "react";

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

  useEffect(() => {
    let ignore = false;

    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((res) => {
        if (!res.ok) {
          throw new Error("Failed to fetch user");
        }

        return res.json();
      })
      .then((data) => {
        if (!ignore) {
          setUser(data);
        }
      })
      .catch((error) => {
        if (!ignore) {
          console.error(error);
        }
      });

    return () => {
      ignore = true;
    };
  }, [userId]);

  return <p>{user?.name ?? "Loading..."}</p>;
}
Enter fullscreen mode Exit fullscreen mode

When userId changes, React runs the cleanup for the previous effect before starting the next one. The old effect's ignore variable becomes true, so its result won't be applied.

Each effect invocation has its own variable. The new effect gets a fresh ignore = false, while the old one stays ignored.

That distinction matters: you aren't disabling updates for the entire component. You're preventing one particular request from applying a result after its effect has become obsolete.
But here's the thing: the request itself is still running.

The browser may continue downloading a response you're going to throw away. The ignore flag protects your application from using an obsolete result, but it doesn't cancel the underlying request.

The analogy I keep coming back to is ordering a pizza and then deciding you don't want it anymore.

The ignore flag is like telling yourself, "I won't eat it when it arrives."

The delivery driver still shows up. The pizza may already be in the oven. You're simply choosing not to use what arrives.

Wouldn't it be better to cancel the order when you can?

Cancelling for real: AbortController

Browsers give us a way to abort certain ongoing operations, including fetch(). It's called AbortController, and it's been wdely supported for years.

Here’s how it works:

import { useEffect, useState } from "react";

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;

    async function fetchUser() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          { signal: controller.signal }
        );

        if (!response.ok) {
          throw new Error("Failed to fetch user");
        }

        const data = await response.json();

        if (!ignore) {
          setUser(data);
          setLoading(false);
        }
      } catch (error) {
        if (ignore || controller.signal.aborted) {
          return;
        }

        setError(error.message);
        setLoading(false);
      }
    }

    fetchUser();

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

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <div>
      <h2>{user?.name}</h2>
      <p>{user?.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

A few things are happening here.

  • We create a controller for each effect invocation.
const controller = new AbortController();
Enter fullscreen mode Exit fullscreen mode

This controller belongs to that particular request lifecycle. When userId changes, the next effect gets a new controller.

  • We pass its signal to fetch().
fetch(url, { signal: controller.signal });
Enter fullscreen mode Exit fullscreen mode

The signal connects the request to the controller. Without it, calling controller.abort() wouldn't cancel this fetch.

  • We abort during cleanup.
return () => {
  ignore = true;
  controller.abort();
};
Enter fullscreen mode Exit fullscreen mode

When the effect is cleaned up, we ask the browser to abort the request and mark that effect as obsolete.

  • We handle cancellation separately from real errors.

An aborted fetch normally rejects with an AbortError. That's expected behavior, not necessarily a failure that should be shown to the user.
Notice that the example uses both controller.abort() and ignore.

Why both?

Because cancellation and guarding against obsolete results are related, but they aren't the same thing. The controller handles the abortable operation; the flag prevents this effect from applying results or errors after it becomes obsolete.

Also, aborting a request doesn't undo a state update that has already happened. Cleanup can only affect work that hasn't already completed.
That's the important distinction.

Where it matters most: search race conditions

This is where things get interesting.

Imagine a search input that fires a request whenever the query changes:

  • The user types em: Request A starts.
  • The user types emi: Request B starts.
  • The user types emily: Request C starts.

Requests don't finish in the order they started:

Request A: "em"    -------------------> finishes last
Request B: "emi"   -------------> finishes second
Request C: "emily" ------> finishes first
Enter fullscreen mode Exit fullscreen mode

If you apply every response without checking whether it's still relevant, Request A overwrites the results from Request C. The input says emily, but the UI shows results for em.

That's a race condition.

Here's a complete search component that handles it:

import { useEffect, useState } from "react";

export default function SearchUsers() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const trimmedQuery = query.trim();

    if (!trimmedQuery) {
      setResults([]);
      setLoading(false);
      setError(null);
      return;
    }

    const controller = new AbortController();
    let ignore = false;

    async function searchUsers() {
      setLoading(true);
      setError(null);

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

        if (!response.ok) {
          throw new Error("Search failed");
        }

        const data = await response.json();

        if (!ignore) {
          setResults(data.users);
          setLoading(false);
        }
      } catch (error) {
        if (ignore || controller.signal.aborted) {
          return;
        }

        setError(error.message);
        setLoading(false);
      }
    }

    searchUsers();

    return () => {
      ignore = true;
      controller.abort();
    };
  }, [query]);

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="Search users..."
      />

      {loading && <p>Searching...</p>}
      {error && <p>Error: {error}</p>}

      {!loading && !error && results.length === 0 && query.trim() && (
        <p>No users found.</p>
      )}

      <ul>
        {results.map((user) => (
          <li key={user.id}>
            {user.firstName} {user.lastName} ({user.username})
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every time query changes, React cleans up the previous effect before running the next one. That cleanup aborts the old request and marks its effect as obsolete.

The ignore flag is the extra safeguard: even if an asynchronous operation has already progressed beyond the point where cancellation can help, the obsolete effect won't apply its result.

One detail worth noticing: this component keeps the previous results visible while a new search is loading. That's a deliberate UI choice, not a requirement. If you prefer to clear the results immediately, you can do that when the query changes.

The important thing is to know which data your UI is showing.

Wait, shouldn't I just debounce the input?

You might be thinking, "I'll just wait until the user stops typing before sending a request."

That's a good idea, too. Debouncing reduces the number of requests by waiting for a pause in typing before starting one. It can save unnecessary API calls, especially when users type quickly.

But debouncing alone doesn't cancel a request that has already started. An earlier request can still finish after a newer one.
Cancellation and debouncing are teammates, not competitors.
Debounce to reduce unnecessary requests. Abort obsolete work. Guard against stale results.

Each technique addresses a different part of the problem.

Ignore flag vs AbortController

Here's the practical difference:

ignore flag AbortController
Prevents obsolete effect results from being applied Yes, if checked consistently Not by itself in every asynchronous workflow
Cancels an in-flight fetch() No Yes, when the signal is passed
Can guard non-abortable async work Yes No, not on its own
Can reduce unnecessary response downloading No Often, if the request is aborted in time
Guarantees the server stops processing No No
Requires the operation to support an abort signal No Yes

About that last row: aborting cancels the request on the client, but it doesn't guarantee the server stops processing it. If the server already received the request, it may finish the work anyway. Sometimes the pizza is already in the oven.

And if the request has side effects, like placing an order or making a payment, client-side cancellation is not a substitute for proper server-side handling.

If you control the server, you can push "sometimes" closer to "yes". Cancellation only helps if it travels all the way. Most frameworks give you a signal that fires when the client disconnects (in Fetch-style handlers it's request.signal), and you can pass it to whatever you call next. This matters most for expensive work like streaming an LLM response: an answer nobody reads is still being generated, and paid for.

export async function POST(request) {
  // MODEL_URL and payload stand in for your upstream API
  const upstream = await fetch(MODEL_URL, {
    method: "POST",
    body: JSON.stringify(payload),
    signal: request.signal, // client disconnects -> upstream call is cancelled
  });

  return new Response(upstream.body);
}
Enter fullscreen mode Exit fullscreen mode

Whether the disconnect reaches your code depends on your framework and hosting, so test it by closing the tab mid-stream.

One more thing: Strict Mode

If you see an effect run, clean up, and run again in development, don't panic.

When Strict Mode is enabled at the root, React deliberately runs an extra setup-and-cleanup cycle for effects on initial mount in development. It checks whether your cleanup properly undoes your setup.

With AbortController, you may see one request get cancelled and another succeed in your Network tab. That's expected in this situation.

Don't hide the extra request by disabling Strict Mode or forcing the effect to run only once. Make sure your setup and cleanup work correctly together. The extra development-only cycle doesn't happen in production.

When should you use this?

Whenever a request can become irrelevant before it finishes:

  • Prop-driven fetches: userId changes, so the previous user's data no longer matters.
  • Search and autocomplete: the query changes while earlier requests are still running.
  • Pagination and filters: the user jumps to another page or changes a filter.
  • Tabs and modals: the user closes the view before the request completes.

For a fetch inside an effect, an abort signal is usually all you need to cancel obsolete in-flight work. For async work that can't be aborted, a flag can prevent its result from being applied after cleanup. For mutations, remember that the server may still act on the request.

What if you use TanStack Query or SWR?

Then you may never write an AbortController yourself.
These libraries handle caching, request deduplication, and keeping results tied to the right query. TanStack Query even gives your query function an AbortSignal that you can pass to fetch, so the request can be cancelled.

The React docs also suggest using your framework's data fetching or a client-side cache instead of fetching everything by hand in effects. There's a good section on this in You Might Not Need an Effect.

But understanding the mechanism still pays off. It helps you debug strange behavior, and it tells you what your tools are doing for you.

Final thoughts

When I first hit this bug, I thought my search logic was broken. What I really needed was a better picture of how async work relates to a component's lifecycle.

A component can disappear. A query can change. A request can become irrelevant. None of that cancels a promise, and none of it undoes work that already happened.

The ignore flag prevents an obsolete result from being applied. AbortController cancels supported in-flight work, which also prevents an unfinished fetch from delivering a stale result in these examples.

And that's what I want to remember: good cleanup isn't just about stopping work. It's about making sure work that no longer belongs to the current UI can't interfere with it.

Because the request doesn't know you've moved on.

Your code has to tell it.

Top comments (5)

Collapse
 
om4rab profile image
om4rAb • • Edited

woow this is so awesome and easy to grasb, i shouldhave came across this like 2 days ago when I was struggling lol
iv run into the same issue on a case where I had orders and clicking an eye icon opens list of order items that were being fetched inside a useEffect, I open the first order i see the items, I open the 2nd I see nothing , back to clicking on the first order, nothing shows and it was a mess till I added abortController
it was a lifesaver

Collapse
 
highflyer910 profile image
Thea •

Thank you, so glad it helped:)
Clicking through orders is exactly the case where an old request can come back late and win. Great catch with AbortController🙌

Collapse
 
om4rab profile image
om4rAb • • Edited

yeah
One question, Do you have any articles about deploying websites like Nextjs-supabase, best practices, things to avoid
Im almost done and want to publish it

Collapse
 
pushpendra2011 profile image
Pushpendra Yadav •

The “server may keep processing” row is where this gets expensive in AI apps. For a streamed LLM response, aborting on the client stops you reading tokens, but the route handler can keep calling the model and paying for output nobody will see. The fix is to forward the cancellation: pass request.signal from the incoming request into the upstream model call, so a user typing a new query actually cancels the pizza and doesn’t just ignore the delivery. One small addition on the client side too: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) gives you cleanup-cancellation and a timeout from a single signal.

Collapse
 
highflyer910 profile image
Thea •

Great point, thanks! I added a short section on forwarding request.signal to the upstream call, since an LLM answer nobody reads is still being generated (and paid for). Nice tip on AbortSignal.any too. Does the disconnect reach your handler reliably behind a proxy?