cf.completefrontend
My progress
FUNDAMENTALS · MODULE 4

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 a matches a million rows; a search for ab matches 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.

A timeline from 0 to 1300 milliseconds with one bar per keystroke request. q=r is sent at 0 and answers at 260 ms. q=re is sent at 100 and answers at 340. q=rea is sent at 200 and answers at 1100, because it hit a slow shard. q=reac is sent at 300 and answers at 420. q=react is sent at 400 and answers at 520. The screen shows older answers until 520 ms, react results from 520 to 1100 ms, and then the late rea answer replaces them.q=rq=req=reaq=reacq=react900 ms: slow shard120 msscreenolder answersreact resultsrea0400 ms800 ms1200 ms
Each bar runs from send to answer. The requests left in order, but 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:

  1. 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.
  2. 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.

Click filters, 250 ms apart:
Protection:
Requests for All, then Shoes, then Hats, with no protection. The selected chip is Hats; the list ends up showing All.Allshown lastShoesreplacedHatsreplaced00.5 s1 s1.5 schip: Hatslist: All
Each bar is one request, from click to answer. All takes 1.4 s, Shoes 1.1 s, Bags 0.6 s, and Hats 0.3 s, so a slow early click can answer after a fast later 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.

A browser window at app.example.com/users/2. The heading reads Profile of user 2, which is correct because it comes from the URL. The form fields show Name Sam Lee, Email sam@acme.io, and Role Admin, which are user 1's details from a late response. Notes say the heading is correct, the fields are wrong, and pressing Save would send user 1's data to user 2.app.example.com/users/2Profile of user 2Name: Sam LeeEmail: sam@acme.ioRole: AdminSaveheading: from the URLuser 2, correctfields: user 1's lateresponse, wrongSave would write user1's data to user 2
The parts that come from the URL update at once; the parts that come from a response update whenever it lands. When the old response lands last, the page disagrees with itself, and a Save turns the read race into a bad 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.

A state machine for one slot of data on screen. From Idle, asking a question q moves to Loading q. From Loading, an answer for q moves to Showing q, and an error for q moves to Error for q. An answer for an old question goes to a dropped box and is never shown. From Showing, a new question goes back to Loading. A note says a new question in any state moves the slot to Loading again.IdleLoading qold data dimmedShowing qError for qOld answerdropped, not shownask qanswer for qerror for qnew questionanswer for an old qnew question in anystate: Loading again
Every state has a question attached. Responses carry their question too, and only a match moves the slot forward; anything else falls into the dropped box.

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.

ts
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);
  }
}
Every place that writes to the screen checks the token: the results, the error, and the loading flag.
A sequence diagram between the search box and the API. The box sends request 1, GET q=rea, then request 2, GET q=react, and latest becomes 2. Response 2 with react results arrives first; 2 equals latest, so it is shown. Response 1 with rea results arrives later; 1 is older than latest, so it is dropped.Search boxAPI#1 GET q=rea#2 GET q=react#2 react results#1 rea resultslatest = 22 = latest: show1 is old: drop
The counter does not care about arrival order. Each response is checked against 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.

ts
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);
  }
}
Each search aborts the one before it, so at most one is in flight and an old one can never write to the screen.

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): fetch rejects 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 the fetch promise rejects.
  • While the body is downloading: fetch has already resolved with a Response, so the rejection moves: response.json() (or text(), 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), check signal.aborted or a token before writing state.
Abort:
Reason:
A fetch of a 120 KB response, aborted waiting for headers with abort(). fetch() promise: rejects with an AbortError. response.json(): never called. Network panel: (canceled). bytes downloaded: 0 KB. server: may still finish the work. your catch: ignore it: you cancelled it.sendwaiting for headersreading the bodydonefetch() promiserejects with an AbortErrorresponse.json()never calledNetwork panel(canceled)bytes downloaded0 KBservermay still finish the workyour catchignore it: you cancelled it
The same abort call has different effects depending on how far the request got. Once the body is fully read, abort does nothing, so a stale result can still reach your state unless you check the signal.

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.

ts
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
  }
}
One signal for the page's lifetime, one for a time limit. Whichever fires first wins, and its reason tells you which it was.
A graph of abort signals. Three sources feed one node, AbortSignal.any, and each is labelled with its reason: the user, through controller.abort, with reason AbortError; the route, through request.signal, with reason AbortError; and a timer, AbortSignal.timeout of 8000 milliseconds, with reason TimeoutError. The combined signal goes to fetch, which rejects with the reason of whichever source fired first.controller.abort()AbortErroruserrequest.signalAbortErrorroutetimeout(8000)TimeoutErrortimerany()fetchrejects withfirst reason
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 fetch fail 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 with signal.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.

Strategy:
Latest wins. Triggers A at 0 ms, B at 200 ms, C at 400 ms; A takes 500 ms, B 900 ms, C 300 ms. A: aborted at 200 ms. B: aborted at 400 ms. C: shown at 700 ms. Ends on C at 700 ms: the newest intent.A at 0aborted at 200 msB at 200aborted at 400 msC at 400shown at 700 ms0500 ms1000 ms1500 msEnds on C at 700 ms: the newest intent
Solid bars are requests running; dashed bars are time spent waiting in a queue. B is the slowest, which is what makes parallel requests to one slot dangerous.

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?

Latest wins

Abort the previous controller (or bump the token), then send the new request.

Only the newest response may write to the slot.

Pros

  • The screen always matches the latest input.
  • Cancelled requests free bandwidth and connection slots.

Cons

  • Under constant input (fast typing with no debounce) nothing ever finishes, so pair it with a debounce.
  • Wrong for writes: cancelling a save does not unsave it.

First wins

While a request runs, ignore new triggers (and usually disable the button).

Re-enable when it settles.

Pros

  • Prevents double submits and duplicate payments by design.
  • Simple: one boolean or one in-flight promise.

Cons

  • Drops real intent if the user genuinely changed their mind.
  • A hung request blocks the action until a timeout fires.

Queue

Chain each request after the previous one settles.

Often coalesced: keep at most one waiting, and replace it with each new trigger.

Pros

  • Preserves order, which matters when each write builds on the last.
  • No lost intent.

Cons

  • Slow: a long queue means a long wait.
  • One failure must decide what happens to everything behind it.

All in parallel

Send every request; key each response by what it asked for.

Each response fills only its own slot or cache entry.

Pros

  • Fastest when requests are independent.
  • No coordination code.

Cons

  • Only safe when the requests really cannot overwrite each other.
  • Can flood the server if triggered in a loop.

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.

Cancelling on unmount and navigation

The rule from the last chapters has a lifecycle version: whoever starts a request owns it, and cancels it when it stops caring. In a UI, a component stops caring in two moments: when the input its request depends on changes, and when it leaves the screen. A page stops caring when the user navigates away.

React: the effect cleanup

In React, the cleanup function returned from useEffect runs in exactly those two moments: before the effect runs again with new dependencies, and when the component unmounts. That makes it the natural place to call abort(). Each run of the effect gets its own controller in its own closure, so there is no shared variable to get wrong.

tsx
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setUser(null); // never show the previous user while this one loads
    setError(null);
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((response) => response.json())
      .then(setUser)
      .catch((err) => {
        if (!controller.signal.aborted) setError(err);
      });
    return () => controller.abort();
  }, [userId]);

  // ...render user, error, or a skeleton
}
When userId changes, the cleanup aborts the old request before the new one starts. On unmount, it aborts whatever is left.
A sequence diagram between the UserProfile component and the API. With userId 1, the effect sends GET /users/1 with signal A. userId changes to 2, so the cleanup calls abort on A, and the new effect sends GET /users/2 with signal B. The response for user 1 is dropped and never read, though the server may still build it. The response for user 2 arrives and setUser runs. A note says that on unmount the cleanup aborts whatever is still running.UserProfileAPIGET /users/1, signal AuserId = 2: cleanup, abort(A)GET /users/2, signal Buser 1: droppeduser 2: setUsermay stillbuild user 1unmount later: cleanup abortswhatever is still running
The cleanup runs before the next effect, so the old request is cancelled before the new one starts. Each effect run has its own controller in its own closure, so there is nothing shared to get wrong.

The React docs show a variant with a boolean: let ignore = false, set to true in the cleanup, and checked before setUser. It is the token idea in its smallest form. It fixes correctness but not waste, so prefer the controller for fetch and keep the boolean for async work that cannot be cancelled.

Two things surprise people. First, in development, React's Strict Mode runs every effect's setup, then its cleanup, then its setup again, so the Network panel shows the first request as (canceled). That is expected: React is checking that your cleanup works.

Second, since React 18, setting state on an unmounted component no longer prints a warning, so a missing cleanup fails quietly. When the input changes, it brings back the race. On unmount, it wastes requests and fires side effects for a screen that is gone, like a toast, a redirect, or an analytics event.

Routers: a signal per navigation

Routers that load data before rendering a page apply the same rule to whole navigations. In React Router, a loader receives a request, and request.signal aborts if the user navigates somewhere else before the loader finishes. TanStack Router passes an abortController to loaders for the same reason. The router can only cancel what it can reach, so pass that signal on to every fetch the loader makes.

ts
export async function loader({ request, params }: LoaderFunctionArgs) {
  const response = await fetch(`/api/projects/${params.id}`, {
    signal: request.signal, // aborted if the user navigates away first
  });
  return response.json();
}

Leaving the page entirely

When the whole document goes away (a full page load, closing the tab), the browser aborts its in-flight fetches for you. That is usually what you want, with one exception: requests that must outlive the page, such as an analytics event or a "user left" signal. Send those with navigator.sendBeacon(url, data) or fetch(url, { method: "POST", body, keepalive: true }). Both let a small request (up to 64 KB in total for keepalive) finish after the page is gone.

Check your understanding · Exercise 6

In development, the Network panel shows every profile request twice, and the first one is marked (canceled). What is the most likely explanation? Pick one.

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.

Cancelling is cheap on HTTP/2 and HTTP/3. On HTTP/1.1, aggressive cancelling (on every keystroke) can cost more than it saves.
QuestionHTTP/1.1HTTP/2HTTP/3
How the browser cancels one requestCloses the whole TCP connectionSends RST_STREAM for that one streamResets that one QUIC stream
Other requests on the connectionNone can share it, so none are hurtKeep going on the same connectionKeep going on the same connection
Cost of the next requestOften a new connection: TCP and TLS handshakes, 100 to 300 ms on mobileNoneNone
Bytes already sent by the serverDropped by the browserDropped; the server stops sending after the resetDropped; 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.

A stack of four layers that an abort travels through. The browser's fetch always stops waiting and reading. It sends RST_STREAM on HTTP/2 or closes the connection on HTTP/1.1 to the CDN or load balancer, which may still finish its own request to the origin, depending on configuration. The app server sees the connection close but keeps working unless its code listens. The database query runs on unless the server explicitly cancels it.Browser: fetchstops waiting and readingalways stopsCDN or load balancermay finish its origin fetchdepends on configApp serverkeeps going unless it listensonly if it listensRST_STREAM or closeconnection closedcancel the queryDatabasequery runs ononly if told to stop
Each layer only stops if the one above tells it and it chooses to listen. The browser's part is guaranteed; everything below is up to how the server side was built.

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:

  1. 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.
  2. An idempotency key. When the user starts the action, generate a unique key (crypto.randomUUID()) and send it with the request, for example in an Idempotency-Key header. 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.
  3. 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.
Two sequence diagrams side by side for a double click on Place order. Without a key: the browser sends POST /orders twice, and the server answers order 881 and then order 882, so there are two orders and two charges. With an idempotency key: the browser sends POST with key k7 twice; the server answers order 881, then returns the stored order 881 for the repeat, so there is one order.BrowserServerwithout a keyPOST /ordersPOST /ordersorder 881order 882two orders, two chargesBrowserServerwith key k7POST, key k7POST, key k7order 881order 881, storedone order, same answer
The client cannot stop the second request from reaching the server, so the server must recognise it. The key belongs to the intent: the same key on every retry and repeat click.

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/like with { liked: true } is safe to repeat; POST /posts/7/toggle-like is 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-Match with the ETag, or a version field). If someone else changed it first, the server answers 412 Precondition Failed or 409 Conflict and 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.

A query cache drawn as a table of keys and values. Keys search r, search re, search rea, search reac, and search react hold 12, 9, 4, 3, and 8 results. The late answer for rea is written into the search rea entry. The screen reads only the search react entry, so it shows the 8 react results.keyvalue["search","r"]12 results["search","re"]9 results["search","rea"]4 results, late["search","reac"]3 results["search","react"]8 resultslate: reascreen: reactthe late rea answer fills its own entry;the screen reads only the react entry
The late answer still arrives, but it is written under its own key. The screen reads the key for the current input, so there is nothing to check and nothing to overwrite. Going back to rea is now instant.

TanStack Query in practice

  • Cancellation. Each query function receives a signal. If you pass it to fetch, 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: keepPreviousData keeps the previous key's data on screen while the new key loads, and isPlaceholderData tells you to dim it.
  • Mutations. In an optimistic update, call await queryClient.cancelQueries({ queryKey }) in onMutate before changing the cache, and invalidateQueries in onSettled. That is the read-after-write fix from the last chapter. Mutations that share a scope id run one after another: a queue.
tsx
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} />;
}
No token and no manual controller: the key separates the answers and the 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.

tsx
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();
});
The test decides the order, so it fails every time on buggy code instead of once in fifty runs.
  • 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.aborted is true.
  • Mutations: click submit twice in one test and assert one POST, or two POSTs 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's page.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

LevelWhat a strong answer includes
JuniorKnows responses can arrive out of order. Fixes it with a request token or AbortController. Cleans up in useEffect.
Mid-levelUses 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.
SeniorRemoves 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?

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 AbortController fixes waste. For reads, do both.
  • Use AbortSignal.timeout() for time limits and AbortSignal.any() to combine reasons to stop. Treat TimeoutError as 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?

Question 2

What does AbortController actually cancel?

Question 3

How would you prevent a user from being charged twice on checkout?

Question 4

When would you not use latest wins?

Question 5

How do libraries like TanStack Query avoid these races, and what do they not solve?

Practice it

These design problems lean on request races and cancellation.

References

Read next