Latency Is a Correctness Variable: The Async Bugs Fast Networks Hide

Your CI is green. Staging is green. Then a customer on a phone types “apple” into your search box, changes their mind and types “banana” — and the screen fills with apple results. Nothing crashed. No test failed. The bug was always there. Your network was just too fast for it to appear.
We usually file latency under performance: something to measure, optimize, and put on a dashboard next to your error rate. But the moment two requests can be in flight at once, or a retry fires, or a client-side timeout makes a decision, latency stops being a performance number and becomes a correctness variable. Change how long things take and your program doesn't just run slower. It runs differently.
Here are the two bug classes fast networks hide, a framework for picking the right fix on each path, and a cheap way to make your test suite find them instead of your users.
Variance is the fuel, not the delay
Ask when your async code last ran under conditions you don't control. On a laptop hitting localhost, a round trip is well under a millisecond. On office Wi-Fi to a service in the same region, 5–20 ms. On a phone on mobile data in a city with patchy coverage, think 150–600 ms, with spikes into the seconds and a lot of jitter in between.
The number that breaks code isn't the average. It's the variance. A race condition needs two requests to swap order, and requests only swap order when their timings vary independently. On localhost, request A and request B both return in about a millisecond, in the order you sent them, every single time. That's not a guarantee your code earned — it's a coincidence of your environment. Add a few hundred milliseconds of throttling, as one recent write-up on testing an app over 3G found, and the coincidence evaporates.
So the real test isn't “is my app slow?” It's “does my app still do the right thing when the timing is wrong?” Only the second question protects you.
Bug class one: the late response that wins
The pattern is familiar. A search box fires a request as the user types. When the response arrives, you put the results on screen. There's no guard on which request a response belongs to, because on a fast connection the last response is always the response to the last request. Type “apple”, pause, then quickly replace it with “banana”. Both requests are in flight together. Whichever the network finishes last — not whichever the user asked for last — writes to the screen.
Two fixes exist, and they are not the same fix wearing different clothes:
- Latest-wins request IDs. Increment a counter each time you fire a request, keep its value, and ignore any response whose ID isn't the newest. That's a handful of lines and no infrastructure. The old request still completes; you simply refuse to use it.
- Aborting the stale request. An AbortController stops your client from waiting on that response. It does not necessarily stop the server from finishing the work it already started.
Choose by cost. A cheap read path gets request IDs, and you're done. An expensive path — a search index, a report build, a model call — gets an abort, because you don't want to pay for work you're going to throw away.
What does not fix it: debouncing (it reduces how often the race happens, it doesn't prevent it), cancelling on unmount (the bug happens while the component is very much mounted), and “we tested it and it looked fine.”
Bug class two: when “slow” looks exactly like “failed”
Imagine an autosave with a 1.2 second client timeout, tuned when saves normally took a few hundred milliseconds. On a bad connection a save can honestly take two or three seconds without anything being wrong. The client can't tell slow from broken. It just sees the timer expire, assumes the worst, and fires a second save while the first one is still in flight and about to succeed on its own. If the endpoint isn't idempotent — meaning doing the same thing twice gives the same result as doing it once — you just wrote the same edit twice.
The first fix most people reach for is an in-flight guard: skip a new save while one is already out. That stops the duplicate, then creates a quieter bug. If the user keeps typing during that save, the newer edit is silently dropped instead of being sent. You traded a duplicate write for a lost one.
The version you actually want queues the newer edit and sends it the moment the current save finishes: one save in flight, always with the latest pending content. Pair that with a server-side idempotency key — a client-generated identifier the server records, so a repeat with the same key is a no-op — and the whole failure class becomes boring.
My strongest opinion here: a client-side timeout should never make a correctness decision. Using one to show a spinner or offer a “try again” button is fine. The moment a timeout means “this failed, so do it again,” you've asked a stopwatch to distinguish slow from broken — something a stopwatch cannot do.
A decision framework: match the primitive to the path
Not every async path needs the same treatment. Pick by what can go wrong:
- Read-only, last request should win → request ID guard. Cheap, no server change.
- Read-only, the request is expensive → abort plus request ID. Cancel the client wait, and let the server notice the cancel if it can.
- A write that must not duplicate → server-side idempotency key, and let the client retry freely.
- A write where the newest edit must survive → one save in flight plus a pending slot that replaces its predecessor.
- Two clients, one record → optimistic concurrency: a version column or ETag, and reject the stale write instead of merging blindly.
- Long-running work → return a job ID immediately and treat the job as the source of truth. The HTTP response is not the result.
Skip what doesn't apply. A settings form that fires exactly one request per submit doesn't need a guard; it needs a disabled button. The guard is for paths where two things can genuinely overlap — and that list belongs somewhere in your repo, because nobody remembers it six months later.
Reproduce it on purpose
You will not catch these bugs by clicking around on good Wi-Fi. Make the failure deterministic instead of hoping for luck: in non-production, let the server sleep based on a query parameter (?_delay=3000), so the “apple takes three seconds, banana takes 200 ms” scenario always reproduces. Then freeze it as a test.
For a wider net, run a small suite with network conditions injected: a few hundred milliseconds of added latency and 20–40% jitter on every call in the flow. Be honest about the trade-off. That suite is slower and will occasionally flag things that turn out to be fine, so run it nightly rather than on every pull request until you've cleared the backlog it finds. Budget maybe 10–20% more CI minutes. That's cheap compared to one dropped customer message.
Once a week, spend thirty minutes walking your five most important flows with Chrome's throttling presets switched on. Note that the presets were renamed: what used to be “Slow 3G” is now just “3G”, sitting between “Slow 4G” and “Offline”. Whatever the label, it's the closest thing to a real phone you'll get for free.
Five questions for every async code review
- Can two of these be in flight at the same time? If yes, what happens when the first one lands last?
- If this retries, is the operation still correct when it runs twice?
- What's the client timeout, and what's the p99 server time? If the timeout is under roughly three times p99, it will fire while everything is fine.
- What does the user see during the seconds when nothing has come back? Silence reads as broken, and broken invites the double-tap.
- What's this user's real worst case — not the office, but the phone on a bad connection?
The near-shore version of this problem
If your team sits in one city on fiber and your users don't, your test environment is a privilege, not a baseline. A user on mobile data in a mid-size Latin American city can see two to four times the round trip of a user in a US metro, with far more variance. Same code, different failure surface.
The fix is mundane. Test one critical flow per sprint on a real device with a real SIM — a mid-range Android, not the newest phone on the desk. Ask support for timings from real users. And when someone reports “it showed me the old results,” believe them: that's a report about ordering, and it's a bug you can reproduce in ten minutes once you're willing to slow yourself down.
What this costs, honestly
Request ID guards cost a few lines per path. Idempotency keys cost a table, a unique index, a TTL and a cleanup job — real work, but it converts a class of incidents into a no-op. A nightly latency suite costs CI minutes and a few false alarms. Optimistic concurrency costs a migration and a 409 error path your UI has to handle.
None of it is free. All of it is cheaper than learning about your ordering assumptions from a customer, in production, on a network you have never tested on. Your code doesn't have a fast-network bug or a slow-network bug. It has a bug — and fast networks have been hiding it for you.
Key Takeaways
- Once requests can overlap, timing decides correctness. Latency is not just a performance number.
- Variance, not average speed, is what flips the order of responses and puts the wrong result on screen.
- Last-write-wins on the client needs an explicit guard: request IDs for cheap reads, abort plus IDs for expensive ones.
- Never let a client timeout decide something failed. Queue the next write, and make writes idempotent on the server.
- Match the primitive to the path: request guard, abort, idempotency key, queue-and-drain, optimistic concurrency, or job ID.
- Make the bug reproducible on purpose with a non-prod delay flag, then run a throttled suite nightly instead of hoping.
- If you build on fast fiber and your users don't, add real-device, real-network testing to your definition of done.