Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~33 min· topic 7 of 7

Topic 14.7

Race Conditions, Deduplication and Caching

In one line

Responses can arrive out of order, so ignore or abort stale requests; share one request between components that need the same data (deduplication); and show cached data at once while refreshing it in the background (stale-while-revalidate). Building a tiny cache shows how data libraries work inside.

Think of it like this

Asking two friends for directions. You ask Ravi, then change your mind and ask Meera about a different place. Meera answers quickly; Ravi answers ten minutes later. If you follow whoever spoke last, you go to the wrong place. And if three people in your car ask the same friend the same question, one phone call is enough. Data fetching has both problems: answers arriving in the wrong order, and the same question asked many times.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Race condition
A bug where the result depends on which of two things finishes first.
Out-of-order response
An answer to an older request arriving after the answer to a newer one.
Ignore flag
A variable set by an effect's cleanup so a finished but outdated request doesn't update state.
AbortController
A browser object whose abort() cancels any request that was given its signal.
AbortError
The error a cancelled request rejects with. Catch it and do nothing.
In flight
A request that has started but not finished yet.
Deduplication
Reusing a request that's already in flight for the same data instead of sending another.
Stale-while-revalidate
Show cached data at once, refresh it in the background, then show the new data.
LRU cache
A cache with a size limit that removes the least recently used entry first.

Step by step

01Reproduce the race

Make the first request slow and the second fast. Without protection, the slow, outdated answer arrives last and wins. The first live example types a then ab.

Reproduce the racediagram
Rendering diagram…

02Ignore what's outdated

The cleanup of the old run sets its ignore to true before the new run starts. When the late answer arrives, its run is marked stale and the answer is dropped.

Results.jsxwhole filejsx
useEffect(() => {
  let ignore = false;
  search(query).then((data) => {
    if (!ignore) setResults(data);
  });
  return () => { ignore = true; };
}, [query]);

03Or cancel it

Abort the old request in the cleanup. The second live example shows each keystroke cancelling the previous search, so only the last one finishes on the "server".

Results.jsxwhole filejsx
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);
    });
  return () => controller.abort();
}, [query]);

04Latest request wins in event handlers

When requests start from clicks rather than effects, number them and keep only the newest.

Picker.jsxwhole filejsx
const latest = useRef(0);

async function choose(id) {
  const requestId = ++latest.current;
  const user = await fetchUser(id);
  if (requestId === latest.current) setUser(user); // an older click lost
}

05Share requests and cache answers

Put a cache between components and the network: a map from key to entry. Reuse an in-flight promise (dedup), return stored data at once (cache), and refresh it in the background when it's older than staleTime (SWR).

Share requests and cache answersdiagram
Rendering diagram…

06Read the cache with useSyncExternalStore

The cache lives outside React, so components subscribe to it with useSyncExternalStore. When an entry changes, the cache notifies subscribers and every component showing that key re-renders. The third live example puts it all together.

Try it yourself

  1. 1

    Swap the speeds

    In the first example, make short queries fast (300) and long ones slow (900). Predict both log lines. Is there still a bug in the unprotected panel? (No: the answers now arrive in order. Races depend on timing, which is why they hide.)

  2. 2

    Remove the abort

    In the abort example, delete return () => controller.abort();. Predict the log: how many server: done lines now, and which result is on screen? Is that luck or correct code?

  3. 3

    Turn off deduplication

    In the cache example, delete the line if (entry.promise) return entry.promise;. Predict how many server: fetch lines the first load prints now. Then change createCache(300) to createCache(0) and predict the fresh remount line.

Code & diagrams

The out-of-order bug and the ignore flag fix New tab

Both panels show AB first. Then the slow answer for "a" arrives: the unprotected panel shows A results under the query ab; the safe panel drops it. Type in the box yourself: one letter, then a second letter quickly.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

after AB answered: No protection: AB results | With ignore flag: AB results
safe: ignored the late answer for "a"
after A answered: No protection: A results | With ignore flag: AB results
Aborting the previous request New tab

Only `rea` finishes on the server. The `aborted` line comes after the next `start` because the cleanup aborts synchronously but the rejection is delivered asynchronously (a microtask). Combine this with a debounce (Topic 10.2) so fewer requests start at all.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

server: start "r"
server: start "re"
client: aborted "r"
server: start "rea"
client: aborted "re"
server: done "rea"
screen: REA results
A tiny cache with deduplication and stale-while-revalidate New tab

Three components, one request (dedup). A remount within 300 ms makes no request (fresh). A remount after that shows the old price at once and refetches in the background (stale-while-revalidate).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

server: fetch price (call 1)
first: header: loading | cart: loading | footer: loading
loaded: header: ₹101 | cart: ₹101 | footer: ₹101
fresh remount: header: ₹101 | cart: ₹101 | footer: ₹101
server: fetch price (call 2)
stale remount: header: ₹101 (revalidating) | cart: ₹101 (revalidating) | footer: ₹101 (revalidating)
revalidated: header: ₹102 | cart: ₹102 | footer: ₹102

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Set state from every response

In the first example, render only the unprotected panel and type a, then ab quickly.

terminal
$ Preview
── what you'll see ──
Query: ab
No protection: A results

Break #2

Treat AbortError as a real error

In the abort example, change the catch to .catch((err) => setState({ status: "error", error: err })).

terminal
$ Preview while typing
── what you'll see ──
(an error message flashes on every keystroke: The operation was aborted.)

Myth vs fact

Myth

Race conditions only happen with slow servers.

Fact

They happen whenever two requests overlap and can finish in either order: caches, CDNs and mobile networks make the order unpredictable even with fast servers.

Myth

Aborting a request makes the server stop working on it.

Fact

Aborting stops the browser waiting and downloading. The server may still finish the work unless it notices the closed connection. Never rely on abort to undo a write.

Myth

The browser's HTTP cache makes my own cache unnecessary.

Fact

The HTTP cache can avoid network trips (with the right headers), but it doesn't deduplicate in-flight fetch calls, notify components of new data, or keep JavaScript objects ready to render. App-level caches solve different problems.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    React's cache() function (for Server Components) and framework request memoisation deduplicate identical calls within one server render, the server-side version of this topic (Phase 20).

  • ▸

    Deduplication needs a key that fully describes the request (URL, params, user). Two different requests that share a key return wrong data to one of them, the same bug as a missing query-key input in TanStack Query.

  • ▸

    AbortSignal.timeout(ms) creates a signal that aborts by itself after a timeout, and AbortSignal.any([a, b]) combines signals, useful to cancel on unmount or after 10 seconds.

  • ▸

    Optimistic updates race too: if a refetch started before your mutation lands after it, it can overwrite the optimistic value with old data. TanStack Query's docs cancel outgoing queries (queryClient.cancelQueries) in onMutate for this reason.

Remember this

  1. 1

    Out-of-order responses. Network requests don't finish in the order they started: a server may answer a short query slowly and a longer one quickly, a cache may answer one instantly, and mobile networks are uneven. If every response calls setResults, the last response to arrive wins, not the last request made. This is a race condition: the result depends on timing. In a search box, typing a then ab can end with A results under the text ab. It's rare on a fast laptop and common on a phone, which is why it slips through testing.

  2. 2

    Fix 1: ignore stale responses. Each effect run gets its own let ignore = false, and its cleanup sets ignore = true (Topic 7.7). Cleanup runs before the next run, so only the latest run's response is used. A variant for event handlers: keep a counter in a ref, const id = ++latest.current, and only apply a response if id === latest.current (latest request wins). Both still let the old request finish on the network; they just ignore its answer.

  3. 3

    Fix 2: abort. AbortController cancels the request itself: create const controller = new AbortController(), pass controller.signal to fetch(url, { signal }), and call controller.abort() in the cleanup. The browser stops the download, saving data and server work, and the old promise rejects with an error whose name is "AbortError", which you must catch and ignore (it's not a real failure). Your own async functions can support it too: listen for the signal's abort event, stop the work, and reject. Data libraries pass a signal to your query function for exactly this (Topic 14.6).

  4. 4

    Request deduplication. When three components mount at the same time and all need the current user, naive code sends three identical requests. Deduplication means: if a request for the same key is already in flight (started but not finished), return that same promise instead of starting another. You keep a map from key to pending promise and remove the entry when it settles. Browsers don't do this for you for fetch calls in JavaScript. Data libraries do it automatically by query key.

  5. 5

    Caching and stale-while-revalidate. A cache stores finished answers by key, so showing the same data again is instant. But cached data can be outdated. Stale-while-revalidate (SWR, a name borrowed from the HTTP Cache-Control: stale-while-revalidate directive, see /system-design for caching) means: show the cached (possibly stale) data immediately, start a request in the background, and replace the data when the fresh answer arrives. Users never stare at a spinner for data they've seen before. A freshness window (staleTime) avoids refetching data that was just loaded, and a size or time limit removes old entries so memory doesn't grow forever (an LRU cache evicts the least recently used entry first).

  6. 6

    Building a tiny cache. The last live example builds the core of a data library in about 40 lines: a Map of entries (data, updatedAt, in-flight promise), a subscribe/get pair so components can read it with useSyncExternalStore (Topic 10.3), deduplication by reusing the in-flight promise, and an ensure function that fetches only when data is missing or older than staleTime. What it leaves out shows why libraries exist: error states and retries, garbage collection, invalidation after mutations, refetch on focus, aborting unused requests, and server rendering. For real apps use TanStack Query, SWR or your framework; build one only to learn, or for a very small need.

Explain it without notes

01

What is a race condition in data fetching, and how do you reproduce one?

02

Compare the ignore flag and AbortController. When would you use each?

03

What is request deduplication and how would you implement it?

04

Explain stale-while-revalidate and what staleTime adds to it.

Practice

01

Write a latestOnly(fn) helper that wraps an async function so only the most recent call's result is delivered (older calls resolve to undefined).

02

Add a 5-second timeout to a fetch in an effect that's also cancelled on unmount.

03

Extend the tiny cache with an invalidate(key) function that marks the entry stale and refetches it if any component is subscribed.

Trade-offs

  • ↔

    Aborting saves bandwidth but adds error handling for AbortError and needs APIs that accept a signal. Ignoring is simpler but wastes the work of every stale request.

  • ↔

    A cache makes repeat views instant but can show outdated data and uses memory; without invalidation rules and size limits, it becomes a source of bugs. That bookkeeping is what data libraries are for.

Done when you can

  • I can reproduce and explain an out-of-order response bug.

  • I can fix it with an ignore flag, a latest-request counter or AbortController.

  • I handle AbortError separately from real errors.

  • I can implement request deduplication with a map of in-flight promises.

  • I can explain stale-while-revalidate and build a tiny cache that does it.