Out-of-Order Responses
"cat" then "car": A leaves first, B returns first, A returns last and overwrites the newer result with the older one. The fix is a rule about which response is allowed to win.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Two requests are in flight for the same piece of the interface. Which response is allowed to write to the cache?
Someone is typing in a search box. They expect the results underneath it to be the results for what is currently in the box — not for something they typed a moment ago and have already replaced.
Fetch on every change and set the state in the then. The last response to arrive is the most recent data, so the UI stays current.
HTTP responses are not ordered. Different connections, different server work, a retry, a proxy, a cold cache on one shard: the second request routinely finishes before the first, and nothing in fetch prevents that (The Lifecycle of One HTTP Request).
- HTTP responses are not ordered. Different connections, different server work, a retry, a proxy, a cold cache on one shard: the second request routinely finishes before the first, and nothing in
fetchprevents that (The Lifecycle of One HTTP Request). - Six characters typed quickly is six requests and six responses in an arbitrary order. The final rendered state is decided by network timing rather than by what the user typed.
- The result is not "briefly wrong then right". It is wrong and it stays wrong until the next keystroke: the input shows "car" and the list shows cats, indefinitely.
- It does not reproduce on localhost, where the round trip is short and consistent, and it reproduces constantly on a phone on a train. That asymmetry is why it ships.
- It is not a search problem. Tab switching, pagination, filter changes and route transitions are the same shape: a parameter changed faster than a round trip (Route Loading Boundaries).
- Debouncing reduces the number of requests without ordering any of them, so a debounced implementation has the same bug at a lower rate — which mostly means it is harder to find.
What is actually happening
In the browser, not in the framework.
- Arrival order is decided by the network and the server.
awaitorders the statements inside one function; it says nothing about how two independent functions' continuations interleave (The Microtask Checkpoint). - There are exactly three protections, and they are not equivalent. Cancellation stops paying for a response you no longer want. Sequencing stamps each request and discards any response that is not the latest. Keying by the query makes the response land in an entry identified by the query it answers, so an old response writes to an entry nothing is rendering.
- Keying is the strongest of the three because it is structural: correctness does not depend on remembering to add a guard at each call site. It is also the one a client cache gives you for free, which is a large part of why keyed caches exist (Query Keys and Invalidation).
- Cancellation alone is not a correctness guarantee.
AbortControllersignals the browser to stop; a response that has already resolved, or one whose handler is already queued as a microtask, may still run. Cancellation is an optimisation with a correctness side effect, not the other way around (Cancelling a Request Nobody Is Waiting For). - An aborted request rejects with an
AbortError. It is a normal, expected control-flow event and must not be rendered as a failure — a UI that shows an error on every keystroke has swapped one bug for a worse one. - Cancelling a mutation is a different thing entirely. Aborting a
POSTstops the client waiting; the server may already have applied it. Abort is not undo (Idempotency Keys: The Mechanism).
What this makes the browser do
And which of it is avoidable.
- Every keystroke that starts a request costs a connection slot, a request, a response, a parse and a render. Browsers cap concurrent HTTP/1.1 connections per origin at a small number — commonly six — so a burst queues behind itself; HTTP/2 multiplexes onto one connection and moves the queueing to the server (HTTP/2: Streams on One Connection, Head-of-Line Blocking).
- Discarded responses are work that was done and thrown away: server time, bandwidth, and a main-thread
JSON.parsefor a result nobody will see (The Real Cost of JavaScript). - Each accepted response re-renders the result list. Without a stable key per result, that is a full teardown and rebuild of the list's DOM on every arrival (What a Mutation Costs).
- Aborting frees the connection slot early, which is the main reason to do it even when sequencing already guarantees correctness.
"cat", then "car"
The canonical case is worth walking through in full because every other instance of this bug is the same shape wearing different clothes. A user types "cat" and a request goes out. They keep typing, the text becomes "car", and a second request goes out. The second one happens to hit a warm path on the server and returns quickly. The first one hits a cold shard, or a retry, or simply a slower route, and returns afterwards.
Both requests succeeded. Both responses are correct answers to the questions they asked. The failure is entirely in the client's rule for accepting them, which was implicitly "the most recent arrival is the most recent truth". Arrival order is not request order, and once you have written that sentence down the fix is obvious.
- Request A in flight (slow path) — Cold cache, a retry, a slower route — the reason does not matter.
- Keystroke: "car" — request B sent — The input now says "car". This is what the user believes they asked.
- Correct results on screen — For two units, everything is right.
- A resolves → entry overwritten with cat results — The older answer wins, because it arrived later.
- Wrong results on screen — and they stay — No error, no spinner, no retry. It persists until the next keystroke.
Notice there is no failure anywhere in this timeline. Every request succeeded and every response was accurate. The only defect is that the cache had no rule about which arrival was allowed to win.
The interleaving, written out
Writing the interleaving as a table is worth the space, because the argument people make against it — "surely the second one comes back second" — only survives while the two orders are held in one's head as the same thing. Separated into two columns, they are visibly independent.
The last three lines are the ones to sit with. The system is behaving exactly as specified at every layer. The bug is an assumption nobody wrote down, which is why it survives code review.
- Keying is structural: the guard cannot be forgotten at a call site because there is no guard.
- Sequencing is local: correct wherever it is written, absent wherever it is not.
- Cancellation is an optimisation that usually also fixes this, and cannot be relied on to always fix it — a resolved response with a queued handler still runs (The Microtask Checkpoint).
- Debouncing is none of the three. It reduces how often the situation arises and changes nothing about the outcome when it does.
t client network cache / UI
-- ---------------------------- --------------------------- --------------------
1 type "cat" -> request A A leaves
2 type "car" -> request B B leaves
3 B arrives (fast path) write: car results
4 input reads "car" list shows cars OK
5 A arrives (slow path) write: cat results
6 input reads "car" list shows cats WRONG
Request order: A, B
Arrival order: B, A <- decided by the network, not by you
Nothing failed. Both requests succeeded. Both responses were correct
answers to the query they carried. The defect is one unwritten assumption:
"the latest arrival is the latest truth".
Three ways to make that assumption unnecessary:
key by the query -> A's response writes to the "cat" entry. Nothing renders it.
sequence -> A's stamp is older than the latest. Discard it.
cancel -> A is aborted at t=2. Nothing arrives at t=5 to discard.Three protections, and one that is not
The three are complementary rather than alternative, and a search box that does all three is not over-engineered — it is keyed for correctness, cancelled to free the connection and to stop wasting server work, and debounced to reduce volume. The mistake is reaching for the fourth thing, debouncing, and stopping there.
The code below shows all three side by side, framework-free. Read the second one closely: the check after the await is the whole mechanism, and the reason it must be after every await — not just the first — is that each one is a fresh opportunity for the world to have moved on.
A parameter can change while a request for the previous value is still in flight. What guarantees the interface shows the current one?
when The request goes through a client cache and every varying parameter can be put in the key. This is the default for anything list-shaped: search, filters, pagination, tabs.
cost One cache entry per distinct query, so it needs a retention policy or a session of typing leaves an entry per prefix (The Client Cache Model).
when The request does not go through a cache, or the transport gives you no signal support — a third-party SDK, a legacy client, a WebSocket request-response pair.
cost A convention rather than a structure. Nothing fails when a new call site omits it, and it must be re-checked after every await, not only the first.
when Always, in addition to one of the above. It frees the connection slot, stops the server finishing work nobody will read, and saves a parse on the client.
cost An over-eager abort — cancelling on every render rather than on every new query — kills the request that mattered. And an abort must not be rendered as an error (Cancelling a Request Nobody Is Waiting For).
when To reduce request volume and server load for per-keystroke queries. Legitimate, and orthogonal.
cost Adds latency to every query including the final one, and provides no ordering guarantee whatsoever. It is not a member of this list; it is here because it is the answer people give.
when Almost never for queries — but the right answer for mutations against one resource, where the second depends on the first having been applied.
cost The user waits for the round trip of every intermediate value, which for a search box is unusable and for an ordered mutation queue is the point (The Offline Mutation Queue).
1// 1. KEY BY THE QUERY — structural. The old response cannot land anywhere2// that is being rendered, because it writes to its own entry.3const results = cache.read(['search', query]) // ["search","cat"] vs ["search","car"]4 5// 2. SEQUENCE — correct even with no cancellation available.6let latest = 07async function search(q: string) {8 const seq = ++latest9 const res = await fetch('/api/search?q=' + encodeURIComponent(q))10 if (seq !== latest) return // check after EVERY await:11 const data = await res.json() // each one is a fresh chance12 if (seq !== latest) return // for a newer query to start13 render(data)14}15 16// 3. CANCEL — stops paying for a response nobody will read.17let controller: AbortController | undefined18async function searchCancelling(q: string) {19 controller?.abort() // the previous one, if any20 controller = new AbortController()21 const mine = controller22 try {23 const res = await fetch('/api/search?q=' + encodeURIComponent(q), {24 signal: mine.signal,25 })26 render(await res.json())27 } catch (err) {28 if ((err as Error).name === 'AbortError') return // expected. Not an error state.29 showError(err)30 }31}Two details carry the weight. The stamp is checked after *every* await, because parsing the body is a second suspension point and a newer query can start during it. And the abort is caught by name and returned from silently — an implementation that routes AbortError into the error state shows a failure on every keystroke, which is a worse bug than the one being fixed.
How to build it
Most important first.
- Key by the query first. Put the search text, the filter and the page into the cache key, and the response for "cat" writes to the "cat" entry — which is not the entry being rendered (Query Keys and Invalidation).
- Add cancellation on top, to stop paying for work nobody will read and to free the connection slot for the request that matters (Cancelling a Request Nobody Is Waiting For).
- Where neither is available — a hand-rolled fetch, a third-party SDK with no signal support — sequence explicitly: an incrementing stamp, checked after every
await, before every write. - Never render an abort as an error. Check the error name and return.
- Debounce to reduce request volume, and still do one of the three. Debouncing is a cost control, not a correctness mechanism, and treating it as one is the most common wrong answer to this problem.
- Apply the same rule to navigation: a loader for the route the user has left must not write into the route they are now on (Route Loading Boundaries, History and Navigation).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Results announced out of order are worse than no announcement at all: the user hears a count for a query they have already replaced, and has no way to know it is stale (Live Regions and Announcement).
- Announce for the current query only, politely, and only once it settles. Announcing on every keystroke means each announcement interrupts the last and the user hears fragments.
- Focus must stay in the input while the list beneath it changes. This is the combobox pattern: the input keeps focus and
aria-activedescendantmoves the visual selection through the options (Accessible Component Patterns, Focus Management). - Mark the results region
aria-busywhile fetching so a screen reader can avoid reading a list that is about to be replaced, and clear it when the current query has settled. - Never move focus into the results as they arrive. A response landing is not a user action, and stealing focus mid-typing makes the field unusable (Keyboard Operability).
What can go wrong
- The guard exists at four of the five call sites. The fifth is the one users find.
- Abort treated as a failure, so a fast typist sees "Something went wrong" flash on every keystroke (Loading, Error, Empty — The States You Did Not Render).
- Cancelling too eagerly — aborting on every render rather than on every new query — so a re-render caused by something unrelated kills the in-flight request and the results never arrive.
- A sequence counter that is per-component rather than per-key, so two different searches on one screen invalidate each other's responses.
- A mutation aborted on unmount, whose effect the server applied anyway, leaving the client convinced nothing happened (Rollback and Reconciliation).
- The mitigation failing: keying by query is correct and unbounded, so a session of typing leaves one cache entry per prefix the user ever typed, and the retention policy was never set (The Client Cache Model).
- The canonical one: request A for "cat", then request B for "car"; B resolves, then A resolves and overwrites the newer result with the older one.
- A response resolving after its component unmounted or its route changed, writing into state or a cache entry nothing renders (Route Loading Boundaries).
- An abort racing a response that has already resolved: the signal fires, but the
thenhandler is already queued as a microtask and runs anyway (The Microtask Checkpoint). - Two components search the same term at once and both write, so the entry is written twice with the same data and re-renders twice unless the in-flight request is shared (Five Components, One Request).
- A retry of request A resolving after request B, so a retry policy reintroduces the exact reordering the guard was added to prevent (Retries, and the Duplicate Order).
- Aborting a request does not stop the server. For anything that mutates, an abort tells you the client stopped listening and nothing about whether the effect happened (Idempotency Keys: The Mechanism).
- A search box that sends every keystroke sends partial and mistyped input to the server, where it lands in access logs and analytics. Some of that input is a password pasted into the wrong field, and some of it is a search a user abandoned deliberately (Session Replay and the Privacy It Costs).
- Discarding a stale response is a client-side decision. The data was still sent over the wire and is still in the process's memory, so it is subject to the same exposure as everything else in the heap (Cross-Site Scripting).
- Per-keystroke requests are a rate-limit surface and an amplification vector: one user typing is dozens of requests, and a slow endpoint behind a search box is a self-inflicted load test (The Rate-Limit Contract).
- "Debouncing fixes it." Debouncing changes how many requests you send. It does not order the ones you do send, and two requests are enough for this bug.
- "
awaitmakes it sequential."awaitsequences the statements in one function. Two invocations of that function have two independent continuations, and nothing coordinates them (The Microtask Checkpoint). - "Cancellation is the fix." Cancellation is the optimisation. Keying or sequencing is the fix, and cancellation makes both cheaper (Cancelling a Request Nobody Is Waiting For).
- "It only affects search." It affects every parameter that can change faster than a round trip: filters, tabs, pagination, date ranges, route changes, and any list whose query is derived from state the user can change quickly.
- "A library handles this." A keyed cache handles it for queries that go through the cache. The
fetchin auseEffectnext to it does not, and that is where this bug lives in applications that already use a caching library.
Measuring it, and what changes in the field
- In the Network panel, sort by the time the response finished, not when it started. The bug is visible the moment the two orders disagree (Debugging the Network).
- Throttle the connection and type quickly. This class of bug is a latency-variance bug, and it does not appear at all until latency varies (A Method for Frontend Bugs).
- Instrument a counter for discarded stale responses. A counter that stays at zero in production is a suspicious counter — it usually means the guard is not on the path you think it is (Real User Monitoring).
- In a component test, resolve the responses in the reverse of the order they were requested and assert the rendered output matches the latest query. That test fails on every implementation that does not have one of the three protections (Component Testing).
- On a high-latency, high-variance connection — mobile, congested wifi, a shared VPN — reordering stops being an edge case and becomes routine (Packet Loss Buys You a Timeout, Not a Retransmit).
- On a slow device the parse and render of each discarded response is main-thread time spent on nothing, which shows up as input lag in the very field the user is typing into (Interaction Responsiveness).
- With a fast typist, the request rate is set by typing speed rather than by anything you designed, unless you debounce.
- On a route change, the previous route's in-flight loaders are still running unless something cancels them, and their responses land in a component tree that has moved on (Client-Side Routing).
- Keying by query is structurally correct and costs an entry per distinct query, so it needs a retention policy or the cache grows with every keystroke.
- Cancellation saves work and costs a class of bug where an over-eager abort kills the request that mattered — usually triggered by a re-render nobody expected.
- Sequencing is the cheapest to add and the easiest to forget, because it is a convention rather than a structure: nothing fails when a new call site omits it.
- Debouncing reduces load and adds latency to every interaction, including the last keystroke, which is the one the user is waiting on.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALResponse reordering is a property of doing more than one request at a time over a network, so it applies to
fetch,XMLHttpRequest, a GraphQL client, a WebSocket carrying request-response pairs and a service worker alike. Only the cancellation API differs. - FRAMEWORK-SPECIFICTanStack Query passes an
AbortSignalinto the query function and keys entries by the query key, so the pattern is handled if the query text is in the key; SWR keys the same way but does not cancel for you; Apollo cancels via its observable subscription and keys by document plus variables; a hand-writtenfetchinside an effect has none of it. The bug survives in every codebase that uses a cache library for most requests and a raw fetch for the rest. - NETWORK-SPECIFICThe frequency of reordering scales with latency variance rather than with latency itself. A uniformly slow connection reorders less than a fast connection with occasional spikes, which is why the bug is common on mobile and rare on a wired LAN.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — "which write wins" with no global clock is the ordering problem in miniature; a monotonically increasing per-client stamp is the smallest thing that gives the client a total order it can trust.
- — Testing & Reliability Engineering — this bug is only reachable by a test that controls resolution order, which makes it the standard argument for controllable fakes over real timing in component tests.