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 itssignal. - 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.
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.
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".
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.
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).
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
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
Remove the abort
In the abort example, delete
return () => controller.abort();. Predict the log: how manyserver: donelines now, and which result is on screen? Is that luck or correct code? - 3
Turn off deduplication
In the cache example, delete the line
if (entry.promise) return entry.promise;. Predict how manyserver: fetchlines the first load prints now. Then changecreateCache(300)tocreateCache(0)and predict the fresh remount line.
Code & diagrams
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
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 resultsOnly `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
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 resultsThree 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
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: ₹102Break 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.
Break #2
Treat AbortError as a real error
In the abort example, change the catch to .catch((err) => setState({ status: "error", error: err })).
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, andAbortSignal.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) inonMutatefor this reason.
Remember this
- 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, typingathenabcan end withAresults under the textab. It's rare on a fast laptop and common on a phone, which is why it slips through testing. - 2
Fix 1: ignore stale responses. Each effect run gets its own
let ignore = false, and its cleanup setsignore = 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 ifid === latest.current(latest request wins). Both still let the old request finish on the network; they just ignore its answer. - 3
Fix 2: abort.
AbortControllercancels the request itself: createconst controller = new AbortController(), passcontroller.signaltofetch(url, { signal }), and callcontroller.abort()in the cleanup. The browser stops the download, saving data and server work, and the old promise rejects with an error whosenameis"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'sabortevent, stop the work, and reject. Data libraries pass a signal to your query function for exactly this (Topic 14.6). - 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
fetchcalls in JavaScript. Data libraries do it automatically by query key. - 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-revalidatedirective, 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
Building a tiny cache. The last live example builds the core of a data library in about 40 lines: a
Mapof entries (data,updatedAt, in-flightpromise), asubscribe/getpair so components can read it withuseSyncExternalStore(Topic 10.3), deduplication by reusing the in-flight promise, and anensurefunction that fetches only when data is missing or older thanstaleTime. 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
What is a race condition in data fetching, and how do you reproduce one?
Compare the ignore flag and AbortController. When would you use each?
What is request deduplication and how would you implement it?
Explain stale-while-revalidate and what staleTime adds to it.
Practice
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).
Add a 5-second timeout to a fetch in an effect that's also cancelled on unmount.
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
AbortErrorand 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
AbortErrorseparately 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.