Build1 publisher3 min readPublished
Throttling to 3G exposed a search race that every unit test had passed
A developer set Chrome DevTools to 3G and found two correctness bugs in code that every existing test had passed, and reproducing the search race on demand still needed server-side delays on top of the throttle.
The Engineer · Build desk

What happened
- A developer set Chrome DevTools network throttling to 3G on an app that had never misbehaved on normal wifi. Two things broke, both correctness bugs in code that had passed every test written for it.
- The search handler awaited a fetch and called setResults with no check on which query the response belonged to. Typing apple and then banana left both requests in flight at the same time.
- With server delays of three seconds for apple and 200 milliseconds for banana, the logs show banana back after 210ms and apple after 3001ms, and the screen ended up showing apple's results.
- The fix increments a counter on every call, keeps that id in the closure, and returns without rendering when a newer request has been sent since this one fired.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint The 3G preset raises the odds of the ordering flip without making it certain, so it cannot be the assertion in a regression test. A server delay you control has to carry that half.
- decision Choosing between request IDs and AbortController is a decision about server work: abort frees the client from waiting while the handler it triggered may still run to completion.
- cost The guard costs one counter and one comparison per call site, so the spend is auditing every component that fires more than one async request.
- exposure On the author's account, any component holding more than one in-flight request is reachable by the same failure, and a suite that only runs on low latency will not flag it.
Ownership has to come from somewhere other than await. When the search function suspends at `fetch`, its continuation holds the query string it was called with and nothing about whether that query is still the one on screen. Two keystrokes produce two calls and two independent resumptions, and each one calls `setResults` when its own promise resolves [4].
Low, consistent latency keeps arrival order close to send order most of the time, and the author of the post is explicit that this is a habit of fast networks and not a guarantee [5]. In the published run, the banana response came back after 210 ms and the apple response after 3001 ms [9]. Subtract them and there are 2791 ms in which the older query's response can land on top of the newer one's [1].
The server-side delays produced that timing. Chrome's throttling reference no longer lists a separate Slow 3G preset; the entry is now just 3G, sitting between Slow 4G and Offline [1]. DevTools 3G throttling applies to every request in the session regardless of which path it takes, so it makes the flip easier to hit without guaranteeing one on any given run [7]. To get a failure that reproduces, the author added artificial server-side delays: three seconds for apple, 200 ms for banana. The logged test ran with throttling off, so the numbers came from the delay alone [8]. The throttle finds the bug, and an injectable delay is what a test can assert against.
The guard is small. A counter increments on every call, each call keeps its own id, and after the await the function returns early when the id no longer matches the latest [10]. The stale response still arrives and is discarded before it reaches state. AbortController is the other option, and the author treats it as a different tradeoff. Aborting stops the client waiting on the old response, but "It doesn't necessarily stop the server from finishing the work it already started," the author wrote [11]. Request IDs let the old request finish and then ignore it, which the author chose because the search endpoint is cheap and nothing needed cancelling server-side [12].
A client-side timeout produced the second bug. Autosave gave up and retried when it had not heard back within 1.2 seconds. Against saves that normally take a few hundred milliseconds, that is reasonable. A throttled save can take two or three seconds with nothing actually wrong [13]. Against a three-second save, the retry fires 1800 ms before the first response could arrive [2].
The wider claim in the post is the author's: stale results from out-of-order requests are not unique to search boxes and turn up anywhere a component fires more than one async request over its lifetime [14]. The post does not measure how often that happens; the evidence is one app and two bugs in one session [3]. I would expect the same pattern in any component that renders an awaited fetch without checking whether its caller is still the current one. The cheapest way to find out in a given codebase is an hour on the 3G preset with one endpoint you can slow down on demand.
What to watch
- One account becomes evidence if someone runs the same throttled session across a whole codebase and counts how many fetch call sites lack an ownership check.
- Whether the artificial server delay gets wired into CI as a standing regression test, since the throttle alone will not fail the build twice.
- Measurements of what AbortController-based fixes leave running on the server, which is the tradeoff the post names but does not quantify.