Request races and cancellation
Responses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
Why responses arrive out of order
A user types react into a search box. The box fetches suggestions on every keystroke, so five requests go out in about 400 ms: r, re, rea, reac, react. Four come back quickly, but the request for rea hits a slow database shard and takes 900 ms. It arrives last, and the dropdown now shows suggestions for rea while the box says react.
Nothing crashed and no error was logged: the code did exactly what it was told, which was to show each response when it arrives. The bug is the assumption that responses arrive in the order the requests were sent. That assumption is false, and this lesson is about what to do instead.
A request race is any bug where the result depends on which of two or more requests finishes first. Cancellation is the other half of the fix: stopping work, in the browser and sometimes on the server, that nobody needs any more.
Why order is not guaranteed
Each fetch is an independent trip. The browser does not wait for one to finish before starting the next, and nothing along the way promises to keep them in order. Here is where the time goes, and each step can vary from one request to the next:
- Server work. Different inputs cost different amounts. A search for
amatches a million rows; a search forabmatches ten thousand. One request may hit a warm cache and another a cold one. - Different servers. A load balancer may send each request to a different machine. One machine is busy with garbage collection for 200 ms; another is idle.
- The network. With HTTP/2 or HTTP/3, many requests share one connection, and each response is sent as soon as it is ready, not in send order. On HTTP/1.1, requests spread over up to 6 connections per host, each with its own speed and its own lost packets.
- Retries and caches. One response comes from the HTTP cache in 2 ms; another goes to the origin. A request that failed once and was retried is now later than one sent after it.
Put together, the time from send to response for the same endpoint easily ranges from 40 ms to over a second. Whenever two requests overlap in time, their finish order is effectively random. Typing fast, clicking fast, and slow networks all make overlap more likely, so races hide on a developer's fast laptop and show up for real users on 3G.
q=rea answered last, so code that renders whatever arrives ends on rea results while the box says react.The two costs of a race
A race costs you in two separate ways, with different fixes:
- Correctness. The screen shows data for a question the user is no longer asking: suggestions for
rea, results for last page's filter, the profile of the user you clicked away from. This is the serious one: it is wrong, and the user may act on it. - Waste. Requests whose answers will be thrown away still use bandwidth, a connection slot, server CPU, and battery. Five keystrokes that each download 40 KB of suggestions waste 160 KB if only the last one matters.
Ignoring stale responses fixes correctness but not waste. Cancelling requests reduces waste, and on its own it also fixes correctness in most cases, but not all: abort does nothing once a body has been fully read, and some async work cannot be cancelled. Good code does both: it ignores anything stale, and it cancels what it can.
Check your understanding · Exercise 1
Which of these can make a request sent later finish earlier than one sent before it? Select all that apply.
The classic bugs, and one rule that fixes them
The same race shows up anywhere a user can change their mind faster than the server answers. Here are the cases that come up most often in bug trackers and interviews.
Autocomplete
This is the example from the last chapter. One request per keystroke (or per pause, with debouncing) means several requests overlap while the user types. A slow early one lands last, and the list no longer matches the box. It is worse when the user presses Enter or the down arrow at that moment: they pick a suggestion for a query they did not type.
Debouncing (waiting about 200 to 300 ms after the last keystroke before sending) reduces the number of requests, but it does not fix the race. A user who pauses, types again, and pauses again still has two requests in flight.
Filters, tabs, and sorting
A product list has category chips. The user clicks Shoes, then Hats a moment later. The Shoes query is heavier and lands second, so the Hats chip is highlighted over a grid of shoes. Tabs on a dashboard, a sort dropdown, a date range picker, and pagination buttons all have the same shape: the control shows the new choice at once, but the data under it is from the old one.
3 requests sent. 130 KB downloaded and thrown away. The list does not match the chip: it shows All.
Detail pages and tab switches
On /users/1, the user clicks through to /users/2 before user 1 has loaded. The URL and the heading say user 2, but user 1's name, email, and avatar fill the form. If the user edits and saves, the save may go to user 2's id with user 1's data. This is the most dangerous kind, because the stale data becomes a write.
Two more cases share the same root. Pagination: requests for page 2 and page 3 overlap, the slower one wins, and the page number disagrees with the rows. Screens that are gone: a response arrives after the user has left the page, and the code updates state nobody sees, shows a toast about a closed dialog, or sends an analytics event for the wrong page.
Like the autocomplete bug, none of these throws an error, so nothing reaches your error tracker. The page looks right and is wrong, which is why these bugs survive for years.
The mental model: one question per slot
Think of each piece of screen that shows fetched data (a results list, a profile card, a table) as a slot. At any moment, the slot is answering exactly one question: the current query, filter, or id. Every request carries the question it was asking. The rule is simple:
Picture a coffee counter with order numbers. You order a coffee, change your mind, and order a tea while both are being made. A careful barista hands over only the cup with your newest order number: that is ignoring stale answers. A better barista also stops making the coffee: that is cancellation.
The slot moves between a few states. A new question can arrive in any state, and it always moves the slot to loading for that new question. A response only moves the slot forward if it answers the current question.
Notice one thing the picture makes clear. When the question changes, the slot must also decide what to show while the new answer loads. Keeping the old data on screen with no sign that it is old is the same bug in slow motion. Either clear it, or keep it but dim it and show a loading indicator, so the user knows it belongs to the previous question.
Check your understanding · Exercise 2
A search box debounces input by 300 ms before fetching. Which statements are true? Select all that apply.
Request tokens and sequence numbers
The simplest fix follows the rule directly. Give every request a number from a counter, remember the number of the newest one, and when a response arrives, compare. If its number is not the newest, drop it. This number is called a request token or sequence number, and the rule it gives you is called latest wins.
let latest = 0;
async function search(query: string) {
const id = ++latest;
setLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const results = await response.json();
if (id !== latest) return; // a newer search started while we waited
showResults(results);
} catch (error) {
if (id === latest) showError(error);
} finally {
if (id === latest) setLoading(false);
}
}
latest when it lands, and only the matching one reaches the screen.Where the check goes
Check after the last await and before you touch state. Every await is a gap where other code can run, including a newer search. A common mistake is to check right after fetch returns and then await response.json(): the body may take another 300 ms to download on a slow phone, and a newer search can start and finish in that gap.
Check every write, not just the happy path: the code above guards three things. Without the guard in finally, the old search finishes, sets loading to false, and the spinner disappears while the new search is still running. Without the guard in catch, a timeout on an old search shows an error banner over good, current results. Both are real bugs that pass a quick manual test.
Comparing the question instead of a number
A counter is not the only kind of token. You can compare the question itself: when the response arrives, is its query still the text in the box? Is its user id still the id in the URL? This reads naturally and survives cases where the counter is hard to share, such as across components.
Be careful with one edge: the user types cat, then cats, then deletes back to cat. Two requests asked the same question. The first cat response is now equal to the current question, so it passes the check, but it may land after the second cat response. Here that is harmless (both answers are about cat), but if the data changed between the two requests, a counter is the stricter guard.
One counter per slot
Use one counter per slot, not one for the whole app. If the search results and an unrelated sidebar share a counter, each new sidebar request makes every search response look stale, and searches stop showing up at all. In a component, the counter belongs in a ref (useRef) or in the closure of an effect, next to the state it protects.
Ordering requests versus ordering data
A client counter orders requests: it answers "which question did I ask last?". Some races are about ordering data instead. A poll and a WebSocket push both deliver the same order's status; a background refetch and a just-finished save both deliver the same document. There, the right question is "which copy is newer?", and only the server can answer it.
For those cases, have the server send a version that can be compared, such as an incrementing version field or an updatedAt timestamp from the database. (An ETag tells you two copies differ, but not which is newer.) The client keeps whichever copy has the higher version, no matter which arrived last. Interviewers like this distinction, because a request counter alone cannot fix a race between two different channels.
Check your understanding · Exercise 3
A developer checks id !== latest right after await fetch(...), then calls await response.json() and shows the results. What can still go wrong? Select all that apply.
AbortController and AbortSignal
AbortController is the web platform's standard way to cancel async work. It has two parts. The controller is the remote control: whoever holds it can call abort(). The signal (controller.signal, an AbortSignal) is the wire you hand to the work: fetch and other APIs listen to it and stop when it fires.
Splitting the two is deliberate. You can pass the signal to code you don't trust to cancel anything; it can only listen. Only the code that started the work keeps the controller.
let controller: AbortController | null = null;
async function search(query: string) {
controller?.abort(); // cancel the previous search, if any
const current = new AbortController();
controller = current;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: current.signal,
});
showResults(await response.json());
} catch (error) {
if (current.signal.aborted) return; // we cancelled it: not an error
showError(error);
}
}
What abort() does, phase by phase
What happens depends on how far the request got. Try each moment in the playground below.
- Before the request is sent (the signal is already aborted):
fetchrejects at once and nothing goes over the network. - While waiting for the response headers: the browser stops the request (DevTools shows it as
(canceled)) and thefetchpromise rejects. - While the body is downloading:
fetchhas already resolved with aResponse, so the rejection moves:response.json()(ortext(), or the stream reader) rejects instead, and the download stops. - After the body is fully read: nothing happens. The data is already yours. If your code does more async work after reading it (another
await), checksignal.abortedor a token before writing state.
Aborted waiting for headers: fetch rejects with an AbortError; response.json() never called.
Abort reasons and error names
abort() takes an optional reason. With no reason, the signal's reason is a DOMException named AbortError, and fetch rejects with it. With a reason, such as controller.abort(new Error("superseded")), fetch rejects with exactly that value. Current versions of Chrome, Firefox, and Safari all do this.
That is why the code above asks the signal (current.signal.aborted) rather than checking error.name === "AbortError". The name check misses custom reasons, and code that misses them shows the user an error for a cancel they caused. Keep the name check for libraries that throw their own AbortError.
AbortSignal.timeout()
fetch has no timeout option, so a request to a hung server can wait for minutes. AbortSignal.timeout(8000) returns a signal that aborts itself after 8 seconds, with a DOMException named TimeoutError as the reason. The different name matters: a timeout is a real failure the user should hear about ("This is taking too long. Try again."), while an AbortError from a superseded search should stay silent.
AbortSignal.any(): combining reasons to stop
A real request often has several reasons to stop: the user typed again, the page is closing, or it took too long. A fetch takes only one signal. AbortSignal.any([a, b, c]) returns a signal that aborts as soon as any of its inputs does, and takes that input's reason. It has been in all major browsers since 2024.
async function loadReport(id: string, pageSignal: AbortSignal) {
const signal = AbortSignal.any([pageSignal, AbortSignal.timeout(8000)]);
try {
const response = await fetch(`/api/reports/${id}`, { signal });
return await response.json();
} catch (error) {
if (signal.reason?.name === "TimeoutError") throw new Error("Report timed out");
throw error; // includes the page's own AbortError: the caller ignores it
}
}
fetch takes one signal, so you combine. The combined signal fires once, with the first reason, and the reason's name tells your catch whether to stay quiet or tell the user.Details that bite
- Controllers are single-use. Once aborted, a signal stays aborted. Reusing it makes every later
fetchfail at once. Create a new controller per request. - Your own async code can listen too. Call
signal.throwIfAborted()between steps of a long job, or listen withsignal.addEventListener("abort", stop, { once: true }). - Other APIs accept signals.
addEventListener(type, fn, { signal })removes the listener when the signal aborts, which makes cleaning up many listeners one line. Streams (pipeTo) and Web Locks take signals as well. AbortSignal.abort(reason)returns a signal that is already aborted. It is handy in tests and as a default.
Check your understanding · Exercise 4
You call controller.abort() after fetch resolved but while response.json() is still reading a 2 MB body. What happens? Pick one.
Latest wins, first wins, or queue
So far the answer has always been "the newest request wins". That is right for search, but not for everything. When a new request arrives while an old one is still running, you have four sensible choices, and picking the right one per feature is a sign of a senior engineer.
- Latest wins: cancel or ignore the old request, run the new one. The user's newest intent is the only one that matters.
- First wins: keep the running request, ignore the new trigger until it finishes. The first intent is complete and repeats are accidents.
- Queue: let the running request finish, then run the new one. Every intent matters, and so does their order.
- All in parallel: run them all, each into its own slot. The requests are about different things, so they cannot clash.
If you have used RxJS, these are its four mapping operators: switchMap (latest wins, with cancel), exhaustMap (first wins), concatMap (queue), and mergeMap (all in parallel). The names are worth knowing because they come up in Angular codebases and interviews.
Ends on C at 700 ms: the newest intent. Use it for reads that feed one slot: search, filters, detail pages.
Decision A new request is triggered while one is still in flight. What should happen?
What we pick: Reads that drive one slot (search, filters, detail views): latest wins with cancel. One-shot actions (submit, pay, create): first wins plus an idempotency key. Ordered writes (chat send, reordering a list): queue. Writes where only the final state matters (autosave): a queue coalesced to one waiting request. Independent fetches (rows of thumbnails, prefetches): parallel, keyed by input.
The coalesced queue for autosave
Autosave is the classic case for a queue, with one twist. A user edits a document for 10 seconds, triggering 40 saves. Saving all 40 in order is slow and pointless; only the last state matters. But cancelling a save in flight is unsafe, because the server may already be writing it, and two saves in parallel can land in the wrong order and leave the older text on the server.
The answer is a queue of length one: at most one save in flight and at most one waiting. Each new edit replaces the waiting save. When the in-flight save finishes, the waiting one (with the newest content) goes next. Writes stay in order, and there are never more than two.
Check your understanding · Exercise 5
Match the feature to a sensible strategy. Which pairs are right? Select all that apply.
What cancelling does on the server, and what it doesn't
Calling abort() is a decision in the browser. It stops the browser from waiting and from downloading. Whether anything stops on the server is a separate question, and the honest answer is: often not, unless the server was built to notice.
What goes over the wire
How the browser tells the server depends on the HTTP version. This matters for performance, not just for the server.
| Question | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| How the browser cancels one request | Closes the whole TCP connection | Sends RST_STREAM for that one stream | Resets that one QUIC stream |
| Other requests on the connection | None can share it, so none are hurt | Keep going on the same connection | Keep going on the same connection |
| Cost of the next request | Often a new connection: TCP and TLS handshakes, 100 to 300 ms on mobile | None | None |
| Bytes already sent by the server | Dropped by the browser | Dropped; the server stops sending after the reset | Dropped; the server stops sending after the reset |
Inside the server
By the time the reset arrives, the handler may be halfway through. Most server code does not stop on its own: it finishes the database query, renders the JSON, and tries to write it to a connection that is gone. The work is wasted, but it is still done.
A server can notice and stop. In Node.js, the response emits a close event; if it fires before the response finished (res.writableFinished is still false), the client went away. Runtimes built on the Fetch API, such as Deno, give each handler a request.signal that aborts on disconnect. The server can pass that signal on to its own outgoing fetch calls and to database clients that support cancelling a query.
Without that wiring, a search a user abandoned 2 ms in still costs a full 900 ms query.
Middle layers add their own behaviour. A CDN or load balancer may keep its request to your origin going after the browser leaves, for example to fill its cache for the next user. A BFF (backend for frontend) that fans out to five services must cancel all five, or four keep running.
Aborting a write does not undo it
This is the most important limit. Abort a POST /orders and the browser stops waiting, but the order may already be in the database. Worse, the client now does not know: the abort removed the only message that would have told it. The order is in an unknown state.
- Cancel reads freely. A read has no effect, so stopping it is always safe.
- Don't cancel writes because the user moved on. Let them finish in the background, and update or invalidate the cache when they do.
- When a write times out, treat the result as unknown. Read the state back, or retry with an idempotency key so the server can tell it is the same write (next chapter).
Check your understanding · Exercise 7
A dashboard aborts a slow GET /reports/42 after 8 seconds with AbortSignal.timeout(). Which statements are true? Select all that apply.
Races in mutations: double submits and out-of-order writes
Reads race to the screen. Writes race to the database, and the damage lasts: two orders, two payments, or a setting that ends up the opposite of what the user chose. The fixes are different from the read side, because you usually cannot cancel a write.
The double submit
A user taps "Place order", sees nothing happen for 2 seconds on a slow network, and taps again. Or they double-click out of habit, or press Enter in a field and also click the button. Two POST /orders requests go out, both succeed, and the card is charged twice. Retries cause the same thing: a request times out on the client, the code retries, and the first attempt had in fact succeeded.
There are three layers of defence, and serious products use all three:
- First wins in the UI. While the request is pending, ignore further submits and show progress on the button ("Placing order..."). Track it with an in-flight flag (a ref or a plain variable) set synchronously on the first click, not with React state alone: a second click or Enter that arrives before the re-render can still read the old state.
- An idempotency key. When the user starts the action, generate a unique key (
crypto.randomUUID()) and send it with the request, for example in anIdempotency-Keyheader. The server stores the key with the result. A second request with the same key gets the stored result instead of a second order. Stripe's API works this way, and the header is being standardised at the IETF. - A constraint in the database. A unique index on the key (or on something natural, like one vote per user per poll) is the last line, and it holds even if two copies of the request hit two servers at the same moment.
Generate the key per intent, not per attempt. A key made inside the retry loop changes on every retry, and the server sees three different orders. Make it when the user presses the button (or when the checkout page opens), keep it through every retry, and throw it away only when the order is confirmed or the user changes the cart.
Out-of-order writes
A user taps a like button three times fast: like, unlike, like. The UI ends on liked, but three requests go out, and if unlike reaches the server last, the server ends on not liked. The next page load shows the heart empty, and the user thinks the app is broken.
- Send state, not toggles.
PUT /posts/7/likewith{ liked: true }is safe to repeat;POST /posts/7/toggle-likeis not. This makes retries harmless, but state writes can still arrive out of order, so pair it with the next point. - Queue writes to the same thing. One in flight per item, the newest waiting, as in the autosave queue. The last state the user chose is the last one the server sees.
- Let the server reject stale writes. Send the version you edited (
If-Matchwith theETag, or aversionfield). If someone else changed it first, the server answers412 Precondition Failedor409 Conflictand the client can reload and merge.
The refetch that undoes a save
A subtler race mixes a read and a write. A background refetch of a todo list starts, then the user ticks a todo and the UI updates at once (an optimistic update). The refetch started before the save reached the server, so it returns the old list, lands, and unticks the todo on screen. A second later the save finishes and nothing fixes the screen.
The fix is an order of steps: before applying an optimistic update, cancel any in-flight reads of the same data; after the write settles, refetch. Then no read that started before the write can land after it. Server-state libraries give you these two steps as functions, which is the next chapter.
Check your understanding · Exercise 8
A payment form retries a timed-out POST /payments up to 3 times. Which design avoids double charges? Select all that apply.
How server-state libraries handle it, and how to test it
Writing tokens and controllers by hand in every component works, but it is easy to forget one guard. Server-state libraries such as TanStack Query, SWR, and RTK Query remove most of these races by design.
The key idea: a cache keyed by the question
These libraries store every response under a key made from the question: ["search", "rea"], ["user", 2]. A component asks for the key that matches its current input and renders what is stored there. A slow response for rea is written into the rea entry, which nobody is reading any more. It cannot reach the screen showing react.
This removes the race structurally instead of with a check. It also gives you something hand-written code rarely has: if the user goes back to rea, that answer is already cached and shows instantly.
rea is now instant.TanStack Query in practice
- Cancellation. Each query function receives a
signal. If you pass it tofetch, the library aborts the request when the query becomes unused (every component reading it unmounts) or is cancelled. If you ignore the signal, the request runs to the end, and its result is still cached under its own key. - Deduplication. Ten components asking for
["user", 2]at once share one request. - Showing old data honestly.
placeholderData: keepPreviousDatakeeps the previous key's data on screen while the new key loads, andisPlaceholderDatatells you to dim it. - Mutations. In an optimistic update, call
await queryClient.cancelQueries({ queryKey })inonMutatebefore changing the cache, andinvalidateQueriesinonSettled. That is the read-after-write fix from the last chapter. Mutations that share ascopeid run one after another: a queue.
function SearchResults({ query }: { query: string }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ["search", query],
queryFn: ({ signal }) =>
fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json()),
placeholderData: keepPreviousData,
});
return <ResultList items={data ?? []} dimmed={isPlaceholderData} />;
}
signal cancels unused requests.SWR and RTK Query follow the same pattern: a cache keyed by the request and deduplication of identical requests in flight. RTK Query also passes a signal to its base query; SWR does not pass one to your fetcher, so it ignores stale answers but does not cancel them. RxJS users get latest wins from switchMap, which unsubscribes from the previous inner request (cancelling it if the inner observable supports that, as fromFetch does).
Testing races on purpose
Races are timing bugs, so a test must control timing. Don't use real delays and hope; make each response resolve exactly when the test says. A deferred (a promise with its resolve exposed) does that: send two requests, resolve the second first, and check the screen.
test("a slow old search never overwrites a newer one", async () => {
const pending = new Map<string, (body: unknown) => void>();
vi.spyOn(globalThis, "fetch").mockImplementation((url, init) =>
new Promise((resolve, reject) => {
const q = new URL(String(url), "http://test").searchParams.get("q")!;
pending.set(q, (body) => resolve(Response.json(body)));
const signal = init?.signal;
signal?.addEventListener("abort", () => reject(signal.reason)); // honour aborts, like real fetch
}),
);
render(<Search />);
await user.type(screen.getByRole("searchbox"), "ab"); // sends "a", then "ab"
pending.get("ab")!(["abacus"]); // the newer answer lands first
pending.get("a")!(["apple"]); // the older one lands last
expect(await screen.findByText("abacus")).toBeVisible();
expect(screen.queryByText("apple")).toBeNull();
});
- Assert the other effects too: the loading flag is still on until the newest answer lands, no error is shown for a cancelled request, and the old request's
signal.abortedistrue. - Mutations: click submit twice in one test and assert one
POST, or twoPOSTs with the same idempotency key. - In the browser: use DevTools throttling ("Slow 4G"), give your mock server a random delay of 100 to 1,500 ms, or use Mock Service Worker's
delay(). In end-to-end tests, Playwright'spage.route()can hold one URL longer than another to force the bad order.
Check your understanding · Exercise 9
A team moves search to TanStack Query with queryKey: ["search", query], but their queryFn ignores the signal. What do they get? Select all that apply.
Using it in a system design interview
Request races rarely get their own interview question. They show up inside other ones: design an autocomplete, a product listing with filters, an inbox, a checkout, a dashboard with tabs. Bringing them up before the interviewer does is one of the clearest signals that you have built real frontends.
When to bring it up
- Any input that triggers a fetch (typeahead, filters, sort, pagination, tabs): say "responses can arrive out of order, so only the latest one may render" while you describe the data flow.
- Any create or pay action: mention double submits and idempotency keys when you get to the API.
- Any route with loading data: mention cancelling on navigation when you describe routing.
- Any optimistic update or real-time channel: mention ordering by server version when two sources can update the same data.
What to draw and say
You don't need a separate diagram. On the data-flow diagram, mark the component's request with a note: "latest wins, abort previous, key cache by params", and add the Idempotency-Key header to the create endpoint on the API. If there is time, draw the out-of-order timeline from the first chapter in ten seconds: two bars, the later one finishing first.
A good short script for an autocomplete: "I debounce input by about 250 ms to cut requests. Each request gets an AbortController, and a new query aborts the previous one. Results are cached by query, so a late response can never overwrite newer results, and going back to a previous query is instant. On HTTP/2 cancelling is cheap, and cancelled AbortErrors are never shown as errors."
How deep to go, by level
| Level | What a strong answer includes |
|---|---|
| Junior | Knows responses can arrive out of order. Fixes it with a request token or AbortController. Cleans up in useEffect. |
| Mid-level | Uses both ignore and cancel, guards loading and error state, handles AbortError quietly, uses AbortSignal.timeout(), picks latest wins vs first wins per feature, and prevents double submits. |
| Senior | Removes races structurally with a keyed cache, adds idempotency keys per intent, orders data by server version across channels, knows cancelling is best effort on the server and costly on HTTP/1.1, and has a test strategy that forces the bad order. |
Think first
An interviewer asks you to design an analytics dashboard with a date range picker and six charts, each loading its own data. The user changes the range three times in two seconds. What happens, and what do you design?
Without care, 18 requests go out, they land in any order, and each chart may show a different range. Some charts could show the first range while others show the last.
Design: each chart's query is keyed by [chart, range], so a late response for an old range is stored under the old key and never rendered. One AbortController per range change (or the library's signal) cancels the 12 stale requests, which matters because analytics queries are heavy.
Debounce the picker by about 300 ms if it emits while dragging. While the new range loads, keep the old charts dimmed with a spinner rather than blank, so the page does not flash. On the server, make sure the report endpoints listen for disconnects or have statement timeouts, because cancelled heavy queries otherwise keep running.
Summary
- Responses do not arrive in the order requests were sent. Any time two requests for the same slot overlap, the older one can land last.
- Only the answer to the slot's current question may reach the screen. Guard results, errors, and loading state, and check after the last
await. - Ignoring stale answers fixes correctness; cancelling with
AbortControllerfixes waste. For reads, do both. - Use
AbortSignal.timeout()for time limits andAbortSignal.any()to combine reasons to stop. TreatTimeoutErroras a failure and your own cancels as silent. - Pick the strategy per feature: latest wins for reads, first wins for one-shot actions, a queue for ordered writes, parallel for independent requests.
- Cancel on unmount and navigation: effect cleanups and router loader signals. Cancelling is best effort on the server and never undoes a write.
- For writes, use first-wins buttons, idempotency keys made per intent, and server versions to order data from different channels.
- Keyed caches in server-state libraries remove read races structurally. Test races by controlling the order responses resolve in.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
A search box shows results for an older query than the one typed. What is happening, and how do you fix it?
Several requests were in flight at once, and an older one finished last. Nothing guarantees responses arrive in send order: server work, caches, different servers, and the network all vary per request.
Fix correctness by letting only the latest request write to the screen: a sequence number checked after the last await, guarding results, errors, and the loading flag. Fix waste by aborting the previous request with an AbortController when a new query starts.
Add a debounce of about 250 ms to send fewer requests, and ideally cache results by query so going back is instant and late answers land in their own cache entry.
Question 2
What does AbortController actually cancel?
On the client, everything: fetch rejects (or the body read rejects, if headers already arrived), the download stops, and no success code runs. If the body was fully read, abort does nothing.
On the wire, HTTP/2 and HTTP/3 reset one stream; HTTP/1.1 has to close the connection, so the next request may pay for a new handshake.
On the server, nothing automatically. The handler only stops if it listens for the disconnect and passes cancellation on to its database and downstream calls. And an aborted write may still have been committed.
Question 3
How would you prevent a user from being charged twice on checkout?
In the UI, make submit first-wins: an in-flight flag set synchronously on the first click, the button showing progress, and repeat submits ignored.
In the API, send an idempotency key created once per intent (when the user presses Pay) and reused on every retry. The server stores the key with the result and returns the stored result for repeats.
In the database, a unique constraint on that key, so two copies hitting two servers at once still produce one charge. Never rely on aborting a payment request: it does not undo anything.
Question 4
When would you not use latest wins?
For one-shot actions like submit or pay, use first wins: the second trigger is usually an accident.
For ordered writes, like chat messages, use a queue so the server sees writes in the user's order. When only the final state matters, as in autosave, coalesce it to one in flight and one waiting.
For independent requests like thumbnails, run them in parallel, keyed by input. Latest wins with cancel is for reads that feed one slot.
Question 5
How do libraries like TanStack Query avoid these races, and what do they not solve?
They store each response under a key built from the request, and components read the key for their current input, so a late response goes into a cache entry nobody is reading. They dedupe identical requests and pass a signal to the query function so unused requests can be aborted.
They also give you cancelQueries to stop in-flight reads before an optimistic update, so an old refetch cannot undo it.
They don't prevent double submits or out-of-order writes on the server. That still takes pending state, idempotency keys, and versioned writes.
Practice it
These design problems lean on request races and cancellation.
- Design a tabs componentEasy
- Design a pagination controlEasy
- Design breadcrumbsEasy
- Design a like button for millions of usersEasy
- Design an autocompleteMedium
- Design an infinite scroll listMedium
- Design a command paletteMedium
- Design an @-mention inputMedium
- Design an emoji and GIF pickerMedium
- Design a search results pageMedium
- Design an e-commerce cart and checkoutMedium
- Design an LLM chat interface with streaming answersMedium
- Design a grocery delivery slot pickerMedium
- Design an online chess gameMedium
- Design a scheduling link like CalendlyMedium
- Design a checkout flowHard
- Design AirbnbHard
- Design a flight booking siteHard
- Design an analytics dashboardHard
- Design Google MapsHard
- Design a long insurance application formHard
- Design a stock trading appHard
- Design a writing assistant like GrammarlyHard
References
- AbortController MDN: the controller, abort(), and examples with fetch.
- AbortSignal MDN: reason, throwIfAborted(), timeout(), and any().
- Synchronizing with Effects: fetching data React docs on effect cleanups and the ignore flag for races.
- Query Cancellation TanStack Query: how the signal reaches your query function and when it aborts.
- Idempotent requests Stripe API: idempotency keys in a real payments API.
- The Idempotency-Key HTTP Header Field IETF HTTP API working group draft.
Read next
- Deduplication and batchingWhen many parts of a page ask for the same data at once, send one request and share the answer; when they ask for many small pieces at once, collect them into one request. Two small ideas that remove most of the wasted traffic in a component-based app.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.