CachingGENERALFRAMEWORK-SPECIFICNETWORK-SPECIFIC

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.

The question

Two requests are in flight for the same piece of the interface. Which response is allowed to write to the cache?

The user intent

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.

The obvious build

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.

Why it breaks

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).

How it breaks in a real browser
  • 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).
  • 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.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Arrival order is decided by the network and the server. await orders 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. AbortController signals 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 POST stops 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.parse for 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.

Two searches, two round trips, one entryrelative units — a shape, not a measurement
Keystroke: "cat" — request A sent
Request A in flight (slow path)
Keystroke: "car" — request B sent
Request B in flight (fast path)
B resolves → entry written with car results
Correct results on screen
A resolves → entry overwritten with cat results
Wrong results on screen — and they stay
  • Request A in flight (slow path)Cold cache, a retry, a slower route — the reason does not matter.
  • Keystroke: "car" — request B sentThe input now says "car". This is what the user believes they asked.
  • Correct results on screenFor two units, everything is right.
  • A resolves → entry overwritten with cat resultsThe older answer wins, because it arrived later.
  • Wrong results on screen — and they stayNo 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.

Which protection belongs here?

A parameter can change while a request for the previous value is still in flight. What guarantees the interface shows the current one?

Key the cache by the query

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).

Sequence and discard stale responses

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.

Cancel the previous request

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).

Debounce the input

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.

Serialise the requests

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).

Keying, sequencing and cancelling
1// 1. KEY BY THE QUERY — structural. The old response cannot land anywhere
2// 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 = 0
7async function search(q: string) {
8 const seq = ++latest
9 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 chance
12 if (seq !== latest) return // for a newer query to start
13 render(data)
14}
15
16// 3. CANCEL — stops paying for a response nobody will read.
17let controller: AbortController | undefined
18async function searchCancelling(q: string) {
19 controller?.abort() // the previous one, if any
20 controller = new AbortController()
21 const mine = controller
22 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-activedescendant moves the visual selection through the options (Accessible Component Patterns, Focus Management).
  • Mark the results region aria-busy while 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

Failure modes
  • 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).
What can arrive out of order
  • 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 then handler 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).
Security
  • 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).
Misreads
  • "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.
  • "await makes it sequential." await sequences 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 fetch in a useEffect next 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

How you would see this
  • 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).
Slow device, slow network, large data, old tab
  • 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).
What this costs
  • 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 AbortSignal into 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-written fetch inside 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.

Performancetail-latency
Domains that do not exist yet
  • 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.