Advertisement

There is a category of front-end bug that cannot be reproduced at a desk. The report says a stale search result appeared, or a form submitted twice, or the wrong user profile flashed up for a second. On a fast connection it never happens. On a train, it happens constantly. These are race conditions, and the reason they hide is that a fast network makes responses arrive in the order they were sent — which is an accident, not a guarantee.

Once you accept that responses can arrive in any order, a whole class of code reads differently. Anything that fires a request and then assumes the next thing it hears back belongs to that request is making a bet, and the bet only pays off while the network is quick.

Below: the two shapes these bugs take, how to make them happen on demand, and the fixes that actually hold rather than the ones that shrink the window.

Shape one: the out-of-order response

The classic is search-as-you-type. Each keystroke fires a request; each response replaces the results list. Type "rea", then "reac", then "react", and three requests go out in that order. If the response for "rea" happens to take longer than the response for "react" — entirely possible, since they are independent requests hitting a server with varying load — it arrives last and overwrites the correct results with stale ones. The user is looking at results for a query they finished typing half a second ago.

What makes this so hard to spot in review is that the code looks obviously correct. Fire request, await it, set state. The flaw is not in any one line; it is that three copies of that sequence are in flight at once and only the last one to finish gets to write. Nothing in the code expresses which one should win.

The same shape appears anywhere a rapid sequence of user actions each triggers a fetch: tab switching, pagination, filter toggles, a list where each row loads its own detail. It is not specific to search.

Advertisement

Shape two: the double submit

The second shape is the opposite: not a response arriving too late, but a second request that should never have been sent. A user clicks Pay, nothing visibly happens because the request is still travelling, so they click Pay again. Two charges. On a fast connection the button disables and the spinner appears before a human can click twice, so the bug is invisible; on a slow one the window is a full second wide and users are trained by experience to click again when nothing happens.

Disabling the button on click is the right first move and is not sufficient by itself, because it only defends the one path you thought of. A page refresh mid-request, a double-tap that fires before your handler runs, a retry from a flaky connection layer — all of them produce the same second request with the button never involved.

The durable defence for anything with a side effect is idempotency at the server: the client generates a key for the operation, sends it with the request, and the server records that key and returns the original result if it sees the key again. Then a duplicate request is not something you have to prevent, only something you have to survive — a much easier property to guarantee.

Making it happen on purpose

These bugs are cheap to find once you stop waiting for them. Every major browser ships network throttling in its developer tools; set it to a slow profile and use the app normally for ten minutes, especially the parts where you type quickly or click through a list. Most ordering bugs of the first shape surface within a few minutes, because you are now typing faster than the responses can return, which is exactly the condition that triggers them.

For the second shape, throttling plus deliberate impatience: click every submit button twice, quickly, on every form that changes something. If the second click can produce a second effect, you have found it. This takes minutes and it is the single highest-yield manual test on most applications.

Automated coverage is possible but needs the test to control timing rather than hope for it: mock the network layer so you can resolve the second request before the first, then assert the final state matches the second. If a test cannot choose the resolution order, it is not testing the race — it is testing the happy path with extra steps.

Advertisement

Fixes that hold, and the one that only narrows the window

Debouncing is the fix people reach for first, and it is worth having — it cuts the number of requests, which is good for the server and good for the user. It does not solve ordering. It makes the window smaller, so the bug happens to fewer users, which is worse than not fixing it: the reports get rarer and less reproducible without ever stopping.

The fix that holds is to make lateness detectable. Either cancel the previous request when a new one starts, so a superseded response never arrives at all, or tag each request and have the handler discard any response that is not the newest. Both express the missing idea directly: only the most recent request may write. Modern browsers give you a cancellation mechanism for fetch, and most HTTP clients expose one too.

The general principle is worth keeping past this particular bug. Any time code sends something and then acts on what comes back, ask what happens if the answer arrives after the question stopped being relevant. On a fast connection, nothing. On a real one, that is where the bug lives — and your users are on the real one.

Why a fast local connection hides an entire category of bug a slow one reliably reveals

Developing and testing exclusively on a fast local or office network means every asset and API response arrives close enough to instantaneously that the actual, real-world timing gaps between them barely exist at all, which is precisely why race conditions dependent on one specific resource loading meaningfully slower than another simply never manifest during normal development — the identical code, tested on an actual slow connection or with network conditions deliberately throttled, can reveal a serious bug that a fast connection structurally could never have exposed no matter how much testing happened on it.

Advertisement

Why a script that assumes the DOM is already fully ready is a specific, common instance of this bug class

A script that queries for a specific DOM element and assumes it already exists, without waiting for a load or DOMContentLoaded event, works reliably on a fast connection where the whole page tends to arrive and parse in a small enough window that the timing usually happens to work out — but on a slow connection, where the script can genuinely execute before the specific element it expects has actually been parsed and inserted into the DOM yet, the exact same code fails intermittently, in a way that looks like a mysterious, hard-to-reproduce bug rather than the straightforward, fixable timing assumption it actually is.

Why deliberately testing under artificial network throttling should be a standard part of the testing routine

Every modern browser's developer tools include a network throttling feature specifically to simulate a slow connection without needing an actually slow physical network to test against, and treating a pass under throttled conditions as a standard, required part of testing any feature involving multiple asynchronous resources — rather than an optional, occasionally-remembered extra step — catches exactly this class of bug before it ever reaches the users on real slow connections who would otherwise discover it first.

Simulating the real world in local development

The core problem is that local development happens on the fastest possible network conditions — localhost, or a company network a few milliseconds from the server — while real users connect over cellular networks, congested wifi, and connections with hundreds of milliseconds of latency and meaningful packet loss. A race condition that requires two requests to resolve in a particular relative order is invisible when both requests take five milliseconds each, and become the dominant failure mode when one takes five milliseconds and the other takes eight hundred.

Chrome and Firefox devtools both include a network throttling feature that can simulate 'Slow 3G' or a custom profile with specified latency and bandwidth, and treating this as a mandatory manual test step — not just for performance work, but for any feature involving more than one asynchronous operation — catches a meaningful share of these bugs before they reach a real user's slow connection. Some teams go further and run their whole automated end-to-end test suite under an artificially throttled network specifically to surface timing-dependent bugs that a fast CI network would hide.

Request cancellation as the actual fix

Once a race condition between two overlapping requests is identified, the durable fix is usually not to add a delay or a lock but to cancel the stale request outright. The AbortController API lets a new request explicitly cancel whatever request preceded it before it starts, so that only the most recent request's response is ever capable of updating the UI — the earlier one either never completes or completes into a controller that has already discarded its result.

This pattern shows up constantly in search-as-you-type inputs, tab switching, and any UI element that can be triggered again before its previous trigger has resolved. Libraries built around this pattern — React Query, SWR, and similar data-fetching layers — bake automatic request cancellation and result-ordering into their defaults specifically because this exact race condition was common enough across enough codebases to be worth solving once, generically, rather than leaving every component to reimplement it correctly or, more often, not at all.

Loading states that expose the race instead of hiding it

A subtle contributor to this class of bug is a UI that shows no loading state at all between a user's action and the eventual result, which makes it look instantaneous during development on a fast connection and only reveals the gap under real latency. Deliberately adding a visible loading indicator, even a brief one, forces the interface to handle the in-between state explicitly rather than assuming it will never be observed, which tends to surface race conditions during normal development instead of only under production network conditions.

Why code review rarely catches this class of bug

A reviewer reading a diff sees the code's logical structure, not its timing behavior, so a race condition that only manifests under specific relative latencies is nearly invisible in a static read-through. This is part of why manual, throttled-network testing and automated tests that explicitly control timing catch bugs that thorough, careful code review consistently misses — the two techniques are looking for fundamentally different classes of defect.