Scenario Debugging

Duplicate Requests, Race Conditions and Stale Responses

Strict Mode running effects twice, double-click submits, responses that arrive in the wrong order, AbortController, removing duplicate requests, and idempotency keys on the server.

Lesson 0515 min read#react#fetch#race-conditions

On this page

  1. Problem 1: the effect fetches twice in development
  2. Problem 2: out-of-order responses (a race condition)
  3. Problem 3: double-click and double-submit
  4. Problem 4: your own frontend triggers rate limits (429)
  5. Problem 5: stale closures and stale state in handlers
  6. Problem 6: request deduplication and caching
  7. Scenario walkthroughs
  8. Common mistakes
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: when you start async work (work that finishes later) from user events and effects, the order of results is not guaranteed and repeats are easy. The fixes come in layers. On the client, cancel what is outdated and ignore stale results. In the UI, disable repeat actions. On the server, make duplicates safe with idempotency.

Key terms used in this lesson:

  • Asynchronous (async) work is work that starts now and finishes later, such as a network request. The program keeps running while it waits.
  • Idempotent describes an action that gives the same result whether you do it once or many times. Example: "set the light to ON" is idempotent. "Toggle the light" is not.
  • Stale means out of date. A stale response is an answer to a question the user has already changed.
  • Effect (useEffect) is a React hook that runs code after the component is drawn, for work outside React such as a request or a timer. Its cleanup is the function you return. React calls it before the effect runs again and when the component unmounts.
  • Debounce means waiting until the user stops (for example, typing) for a short time before you run the work.

These bugs are hard to spot, because each piece of code looks correct on its own. They show up as flickering results, double charges, duplicate rows, and "429 Too Many Requests" errors from your own app.

Problem 1: the effect fetches twice in development

JavaScript
useEffect(() => {  fetch(`/api/items/${id}`).then(r => r.json()).then(setItem);}, [id]);// In dev, the Network tab shows two requests for the same URL

Why: React Strict Mode is a development-only tool that runs extra checks. It is on by default in the Next.js App Router and in many templates. It mounts each component, unmounts it, and mounts it again, once, in development. This checks that your effects clean up correctly. This does not happen in production builds.

What to do:

  • Do not "fix" it by removing Strict Mode. It shows you a real problem: your effect is not idempotent, or it has no cleanup.
  • Make the effect safe to run twice. Abort the first run, or ignore its result (see below).
  • Even better, stop fetching in effects for ordinary data. Use a data-fetching library (React Query, SWR, RTK Query). These libraries deduplicate requests with the same key (they send only one) and cache the results (keep them to reuse later). Or fetch on the server (Server Components, loaders). The React docs recommend this instead of fetching by hand in effects.

Problem 2: out-of-order responses (a race condition)

A race condition is a bug where the result depends on which of two things finishes first. Example: two requests are sent in order 1, 2, but the answer to 2 comes back before the answer to 1.

Scenario: the user types "r", "re", "rea", "reac", "react". Five requests fly. Request 3 ("rea") is slow and arrives last. So the screen shows results for "rea" while the box says "react".

TEXT
type "rea"   ──▶ req A (slow, 900 ms)  ─────────────────────────▶ arrives last → overwrites!type "reac"  ──▶ req B (200 ms)      ──▶ arrivestype "react" ──▶ req C (150 ms)     ─▶ arrives (correct), then A arrives and overwrites it with stale data

The network does not keep requests in order. You must make sure the latest request wins.

Fix A: cancel the previous request with AbortController

AbortController is a built-in browser object that cancels async work. You give its signal to fetch, and you call controller.abort() when the result is no longer needed. The fetch then fails with an AbortError.

JavaScript
useEffect(() => {  const controller = new AbortController();  fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })    .then((r) => r.json())    .then(setResults)    .catch((err) => {      if (err.name !== "AbortError") setError(err);   // aborting is expected, not an error    });  return () => controller.abort();                    // runs before the next effect, and on unmount}, [query]);

Abort cancels the request that is in flight (sent and not yet answered). This saves bandwidth and server work. The server may still process the request, so abort does not guarantee that the work did not run. Abort also makes sure that the stale response never reaches your state.

Fix B: the "ignore" flag (when you cannot abort)

JavaScript
useEffect(() => {  let ignore = false;  fetchResults(query).then((data) => {    if (!ignore) setResults(data);        // only the latest effect run may update state  });  return () => { ignore = true; };}, [query]);

This is the pattern from the React docs. It does not cancel the network call. It only stops the stale result from being used.

Fix C: let a library own it

React Query and SWR are libraries that fetch, cache and refresh server data for you. They store results by query key (["search", query]). A different key is a different cache entry. So an old response can never overwrite the current one. They also abort, dedupe and retry for you.

JavaScript
const { data, isFetching } = useQuery({  queryKey: ["search", query],  queryFn: ({ signal }) => fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json()),  enabled: query.length > 1,  placeholderData: keepPreviousData,       // (React Query v5) show the last results while the new ones load});

Debounce vs abort: they solve different problems

DebounceAbort / ignore stale
ReducesThe number of requests (it waits until the user pauses)Wrong results from overlapping requests
Does not stopA slow earlier request overwriting a later oneSending many requests while typing
Typical useSearch-as-you-type inputEvery fetch that a newer one can replace

For typeahead (suggestions while you type), use both. Debounce (200 to 300 ms) to cut traffic. Abort or ignore so that stale responses never win. See Practical Design Problems, Lesson 06.

Problem 3: double-click and double-submit

A user clicks "Pay" twice. Or the network is slow, so they click again. This gives two requests, two orders and two charges. This is a double submit.

The defence has layers: use both the client and the server.

Client: prevent repeats

JavaScript
function PayButton({ onPay }: { onPay: () => Promise<void> }) {  const [pending, setPending] = useState(false);  const busy = useRef(false);          // a ref changes at once, so a second call sees it immediately  async function handleClick() {    if (busy.current) return;          // ignore a second call that starts before React re-renders    busy.current = true;    setPending(true);                  // disables the button and shows progress    try { await onPay(); } finally { busy.current = false; setPending(false); }  }  return <button disabled={pending} onClick={handleClick}>{pending ? "Processing…" : "Pay"}</button>;}
  • Disable the button and show progress while the request is pending. For forms, use the form's pending state (useFormStatus, useActionState, or isSubmitting from your form library).
  • Use a ref guard (as in the code above) in case a second call starts before React re-renders. A ref (useRef) is a box whose value changes at once and does not cause a re-render, so a second call sees the new value immediately. The pending state alone cannot do this, because the handler still sees the old value from its own render.
  • Navigate away or lock the UI after success, so the action cannot be repeated.

Server: make duplicates harmless with idempotency

You cannot trust the client. There are retries, bad networks, two tabs and API clients. So use an idempotency key. This is a unique ID that the client makes for one attempt (a random UUID works well). Create it when the form is created (not on every click), and send it with the request. The server then makes sure the action happens "at most once". See Backend, Lesson 02:

JavaScript
// Generated once per form instance/attempt, reused on retries of the SAME submission.// Make a new key only for a new order, for example after the first one succeeded.// (crypto.randomUUID() works in secure contexts, that is, HTTPS and localhost)const [idempotencyKey] = useState(() => crypto.randomUUID());await fetch("/api/orders", {  method: "POST",  headers: { "Idempotency-Key": idempotencyKey, "Content-Type": "application/json" },  body: JSON.stringify(order),});

On the server, store the key together with the result, under a unique constraint (a database rule that allows each value only once, so a second insert of the same key fails). If the same request comes again, return the stored response. If the first request is still running, make the second one wait, or answer 409 Conflict, so that the work never runs twice at the same time.

As a last guard, use a natural unique constraint where one exists. For example, UNIQUE (user_id, cart_id) means "one order per cart" (the database rejects a second row with the same pair). A disabled button in the UI is only a convenience. The database constraint is the real guarantee.

Problem 4: your own frontend triggers rate limits (429)

429 Too Many Requests is an HTTP status code that a server sends when a client has sent too many requests in a given time (the server is rate limiting). Duplicate requests can cause it. These are the common causes:

  • Effects without the right dependencies, which fire on every render.
  • Many components that each fetch the same resource (no dedupe).
  • Polling (asking the server again and again) that is too frequent, or polling in every open tab.
  • Retry loops without exponential backoff and jitter. Exponential backoff means waiting longer after each failure (for example 1 s, 2 s, 4 s). Jitter means adding a small random delay, so many clients do not retry at the same moment.
  • The Strict Mode double run in development.
  • Several users behind one company IP address (an address that identifies a network), with a limit for each IP.

Fixes:

  • Dedupe through a shared cache.
  • Poll less often, or use SSE or WebSockets (the server pushes updates, so you do not need to ask again and again).
  • Pause polling when the tab is hidden (document.visibilityState).
  • Back off on 429 and obey the Retry-After header (a response header that says how long to wait, as a number of seconds or as a date).
  • If needed, coordinate the tabs with BroadcastChannel (a browser API for messages between tabs of the same site) or a leader election (the tabs choose one leader to do the work). See Lesson 09.

Problem 5: stale closures and stale state in handlers

A closure is a function that remembers the variables from the place where it was created. A closure is "stale" when it remembers an old value. In React, each render creates new functions, and each one remembers the values of that render.

JavaScript
function Counter() {  const [count, setCount] = useState(0);  useEffect(() => {    const id = setInterval(() => setCount(count + 1), 1000);   // ❌ closes over count = 0 forever    return () => clearInterval(id);  }, []);  // ✅ setCount((c) => c + 1)  — the functional update does not depend on a captured value}

Async callbacks remember the values from the render in which they were created. To avoid old values, you can:

  • Use functional updates (setCount(c => c + 1): you pass a function, and React gives it the latest state).
  • Use refs to read the "latest value".
  • Use useEffectEvent (a React hook for a function that an effect calls and that always sees the latest props and state, without being a dependency).
  • Set the effect dependencies (the array at the end of useEffect) correctly.

See Frontend Depth, Lesson 01.

Problem 6: request deduplication and caching

Many components may ask for the same thing. Here is how to handle it:

  • React Query / SWR: requests with the same key share one request in flight and one cache entry.
  • Next.js (App Router): identical fetch calls (same URL and options) in one server render pass are memoised and deduplicated. Memoisation means remembering the result of a function call, so the same call later reuses the result instead of running again. So several Server Components can call getUser() without making several network requests. This works for fetch only. For a database or ORM call, wrap the function in React's cache() to get the same effect. Caching beyond one render depends on your fetch cache settings and framework version. Check the docs for your version.
  • Your own small dedupe: store the promise that is in flight, by key. A promise is an object that stands for a result that will arrive later.
JavaScript
const inflight = new Map<string, Promise<unknown>>();export function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {  if (!inflight.has(key)) inflight.set(key, fn().finally(() => inflight.delete(key)));  return inflight.get(key) as Promise<T>;}

Scenario walkthroughs

"Fast typing shows results for an older query"

  1. Diagnose: the Network tab shows overlapping requests. A slow earlier one finishes last and overwrites the state.
  2. Fix: on every change, abort the previous request (or ignore stale results). Key the results by query.
  3. Improve: debounce the input. Keep the previous results visible while loading.
  4. Prevent: use React Query/SWR. Add a test with a mocked slow response for the first call.

"How do you prevent a duplicate order submission, from the button to the database?"

  1. UI: disable the button, show a pending state, and guard with a ref.
  2. Request: send a stable Idempotency-Key that you create for each checkout attempt.
  3. Server: keep an idempotency record with a unique constraint. Return the stored response for repeats. Reject the same key if the body is different.
  4. Database: add a natural uniqueness constraint (one order per cart).
  5. Payments: pass the key on to the payment provider too, so it also removes duplicate charges. Many providers (for example Stripe) support an idempotency key header.
  6. Observe: log the key and count the duplicate hits.

Common mistakes

  1. Removing Strict Mode to hide double effects.
  2. No abort/ignore logic, so stale responses overwrite fresh data.
  3. Debouncing only, and believing it solves races.
  4. Relying only on a disabled button to prevent duplicates.
  5. Making a new idempotency key on each retry. This defeats the purpose.
  6. Polling in every tab and never pausing when the tab is hidden.
  7. Using fetch in effects for everything, instead of a data layer that dedupes, or the server.
  8. Treating AbortError as a failure and showing an error screen.

Interview questions

Why does my effect fetch twice in development?

React Strict Mode mounts, unmounts and remounts components in development, on purpose. It does this to show effects that do not clean up or that are not idempotent. It does not happen in production. The right answer is to make the effect safe (abort, or ignore stale results). Or move the fetching to a library or to the server, which dedupes. Do not turn Strict Mode off.

Scenario: fast typing shows results for an older query. Fix it.

Responses can arrive out of order. So an older, slow response overwrites a newer one. In the effect cleanup, I abort the previous request with AbortController (or use an ignore flag). I key the results by query (React Query/SWR). I debounce to cut traffic, and I show the previous results while loading. I also add a test with a delayed first response.

How do you prevent a duplicate order submission, from the button to the database?

I disable the button and show a pending state, with a ref guard. I send an idempotency key that is created once for each attempt. The server stores the key with the response under a unique constraint, and sends the same response again for repeats. I back this up with a natural unique constraint in the database, and I pass the key to the payment provider. The server and database layers are the real guarantee.

Debounce vs abort?

Debounce reduces how many requests you send, because it waits for a pause in the input. But it does not stop an older response from arriving after a newer one. Abort (or ignoring stale results) makes sure only the result of the latest request is used. It also cancels work that is no longer needed. For search-as-you-type, use both.

How can your own frontend cause 429 errors?

These things can cause it:

  • Effects that fire too often.
  • Many components fetching the same data without dedupe.
  • Aggressive polling, especially across many tabs.
  • Retry loops without backoff.
  • The Strict Mode double run in dev.
  • Many users that share one IP.

To fix it, dedupe requests, poll less often or use push channels, back off with Retry-After, and pause polling when the tab is hidden.

Does AbortController stop the server from doing the work?

Not always. It cancels the client's wait and closes the connection. The server may already have processed the request, or may keep processing it, unless it notices the closed connection. So abort prevents stale state and saves some resources. But it does not guarantee that a side effect (a change such as saving data) did not happen. For writes, use idempotency.

Why is a disabled button not enough to prevent duplicates?

A disabled button only protects one page in one browser tab. A second tab or device, a retry after a timeout, a page refresh or a direct API call all skip it. A fast second call can also start before React has re-rendered, so add a ref guard too. So the server must prevent duplicates, with idempotency keys and database constraints.

How does Next.js reduce duplicate server-side fetches?

In one server render pass, identical fetch calls (same URL and options) are deduplicated automatically. So several Server Components can ask for the same data without several network calls. For database calls I wrap the function in React's cache(). To cache across requests, you configure fetch caching or use your own cache layer. This depends on the framework version.

Key takeaways

  • Responses do not arrive in order. Make the latest request win: abort previous requests, ignore stale results, and key data by query.
  • The double effect in Strict Mode is a feature. Write effects that are idempotent and clean up. Or use a data layer that dedupes, or fetch on the server.
  • Debounce reduces traffic. Abort prevents stale overwrites. Use both.
  • Prevent duplicate submissions at every layer: UI pending state → idempotency key → server dedupe → database constraint.
  • Dedupe shared requests, back off on 429, and pause background polling.

What's next

Lesson 06: Slow First Load. We move from runtime behaviour to the first impression: bundles, code splitting, images, fonts and the LCP element (the largest thing that shows on the screen at first).

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Related series