I wasn't looking for a performance problem. I wanted to see what happened when the network got slow enough that assumptions I normally never notice had time to surface. So I opened DevTools, set the throttle to 3G (Chrome's network throttling reference dropped the separate "Slow 3G" preset a while back and just calls it "3G" now, sitting between Slow 4G and Offline), and started clicking around an app that had never once misbehaved on my normal wifi.
Two things broke. Both were correctness bugs, not "feels slow" complaints, and both were sitting in code that had passed every test I'd written for it.
Bug one: a race condition, search results arriving in the wrong order
The app has a search box. Type a query, it fetches results, it shows them. Nothing fancy:
async function search(query) {
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data.results);
}
This is a race condition, and on fast wifi you'll rarely notice it, because request and response times are close enough together that ordering rarely flips. That's not a guarantee, it's just what low, consistent latency looks like most of the time. Slow the connection down and that stops holding. If you type "apple", pause, then quickly change it to "banana", the "apple" request and the "banana" request are both in flight at once. Whichever one happens to take longer, maybe it hit a slower part of the network, maybe the server did more work for it, can land after the other one. When it does, its response overwrites the screen with results for a query you're not even looking at anymore.
If this sounds familiar, I went through a close cousin of this bug in The Optimistic UI Race Condition That Only Showed Up on the Fifth Click, stale results from out-of-order requests aren't unique to search boxes, they show up anywhere a component fires more than one async request over its lifetime.
The 3G throttle on its own makes this easier to hit, since it throttles every request in the session regardless of which path it takes. But it doesn't guarantee a flip on any given run, so to make it reproducible for this post I forced it deterministically: I gave the two queries different artificial delays on the server, apple takes 3 seconds, banana takes 200 milliseconds. That's a server-side delay, not the network throttle. For the logs and timings below, I ran the test with throttling off so the numbers are clean and come from the server delay alone. With throttling on, the same race still happens, but the network adds less predictable latency on top, which makes the timing harder to read in a screenshot.
Click apple, then banana right after.
sent request for "apple"
sent request for "banana"
got response for "banana" after 210ms
got response for "apple" after 3001ms
The screen ends up showing apple's results, because that's whichever response arrived last, not whichever request the user cared about last.
The fix is to stop trusting arrival order and start tracking which request is actually the latest one:
let latestQueryId = 0;
async function search(query) {
const thisId = ++latestQueryId;
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
if (thisId !== latestQueryId) return; // a newer request has already been sent
setResults(data.results);
}
Same two requests, same order of arrival, one result on screen:
sent request for "apple" (id 1)
sent request for "banana" (id 2)
got response for "banana" (id 2) after 210ms
got response for "apple" (id 1) after 3001ms
IGNORED: a newer request has been sent since this one fired
Another option is AbortController, which can abort the previous fetch when a new query starts. That's a different tradeoff from request IDs, not the same mechanism wearing a different name. Aborting stops the client-side fetch from continuing to wait on that response. It doesn't necessarily stop the server from finishing the work it already started. Tracking request IDs instead lets the old request finish, then explicitly ignores its response if it's gone stale. I went with request IDs here because the search endpoint is cheap and I didn't need to cancel anything server-side, just make sure a late response can't win.
Bug two: the autosave that saves twice, or drops your last edit
This one comes from a client-side timeout that assumed a round trip would always be fast.
The autosave gave up and retried if it hadn't heard back within 1.2 seconds, a reasonable number if saves normally take a few hundred milliseconds. On a throttled connection a save can genuinely take two or three seconds without anything actually being wrong. The client can't tell "slow" apart from "failed." It just knows the timer ran out, assumes the worst, and fires a second save while the first one is still in flight and about to succeed on its own.
save #1 sent
save #1 client-side timeout, treating as failed and retrying
save #2 sent
save #1 confirmed (arrived after the retry already fired)
save #2 confirmed
Two saves for one edit. If the save endpoint isn't strictly idempotent, that's two writes that can produce different results depending on what changed in between, not just a harmless duplicate.
My first fix was a simple in-flight guard: skip firing a new save while one is already out. That stops the duplicate, but it introduces a different bug. If you edit again while a save is in flight, that edit gets silently dropped instead of ever being sent:
user types "hello", save #1 starts
user types "hello world" while save #1 is still in flight
skipped (save in flight), edit "hello world" is dropped
save #1 confirmed: "hello"
For this autosave behavior, the version you want doesn't skip the newer edit, it queues it and sends it right after the current save finishes:
let saveInFlight = false;
let pendingContent = null;
async function save(content) {
if (saveInFlight) {
pendingContent = content;
return;
}
saveInFlight = true;
try {
await postSave(content);
} finally {
saveInFlight = false;
}
if (pendingContent !== null) {
const next = pendingContent;
pendingContent = null;
await save(next);
}
}
The try/finally matters here, not just style. Without it, if postSave ever rejects, saveInFlight stays true forever and every future edit gets silently queued and never sent. That's a worse bug than the one you started with, because it fails quietly.
save #1 sent: "hello"
skipped (save in flight), queued latest: "hello world"
save #1 confirmed: "hello"
save #2 sent: "hello world"
save #2 confirmed: "hello world"
No duplicate requests, and the latest edit still gets saved. (If you edit again while that queued save is itself in flight, it just replaces pendingContent again, that's the coalescing working as intended, only ever the most recent edit waits in line.) This is the simple version of the queueing strategy, worth saying plainly: a production autosave also needs an explicit policy for what happens when a save actually fails, not just when it's slow. This code handles slow, not failed.
The actual lesson here isn't "guard against duplicate saves", it's that a client-side timeout only tells you a response hasn't arrived yet. It doesn't tell you what happened on the server, and if the operation has side effects, retrying it needs an explicit strategy for what happens to work that's already in flight.
What both actually have in common
I didn't go looking for two bugs about ordering and timing specifically. I went looking for whatever would show up if I stopped assuming my network was representative of anyone else's. Both of these surfaced because each one was built around an assumption that happened to hold on my machine and broke somewhere else. Responses usually arrive in the order you send them, until they don't. A couple seconds is usually enough time for a save to complete, until it isn't.
Neither of my test suites caught either of these, because neither ran on a slow connection. They ran on the same fast, forgiving network I was developing on, which is exactly the network that hides both of these bugs. Same pattern I ran into with a completely different upgrade in Next.js 16 Broke My App in 4 Places and None of Them Threw an Error, none of these bugs throw, none of them fail loud, they just quietly do the wrong thing until you go looking under the exact condition that triggers them.
If your code assumes responses arrive in request order, or treats a client timeout as proof of failure, you likely have a version of one of these sitting in code you already trust, whether or not you've seen it misbehave yet.







Top comments (26)
Great writeup — the request-ID / idempotency-key thread in the comments already covers the runtime fix well. One thing worth adding, since the post's whole premise is "my tests never caught this": both of these are actually easy to turn into permanent regression tests, without needing DevTools throttling at all. Mock the two fetches with different resolve delays (MSW, a manual
jest.mockdelay, Playwright'sroute.fulfillwith a delay option — whatever you're already using), fire "apple" then "banana," and assert on final state once both resolve. Same idea for the autosave: mockpostSavewith a delay longer than the timeout, trigger two saves, assert the mock only fired the number of times your queueing logic intends. The reason DevTools caught these and the test suite didn't isn't that the tests ran on a fast network — it's that the tests probably never modeled two in-flight requests resolving out of order, which a slow network makes possible but doesn't require. Once that ordering is captured as a fixture, the test catches it every time, on any network.Thank you! The distinction is useful. Slow 3G helped me expose these issues during the investigation, but the regression tests can model the timing conditions directly without depending on network throttling.
For the search race, mocking two requests with different response delays and asserting that the final state matches the latest query would make the test deterministic.
The autosave scenario could be tested similarly by controlling the save timing and asserting the expected queueing behavior and final outcome.
I was investigating what broke under slower network conditions, so throttling was the tool that helped reveal the problems. Turning those observed timing scenarios into permanent test fixtures would be a useful follow-up.
Glad it was useful! One small addition if you do write that follow-up: the autosave test is a nice place to use fake timers (
jest.useFakeTimers()or Vitest's equivalent), so you can step past the 1.2s timeout without the test actually waiting. It'd also be worth asserting that the second edit ("hello world") actually gets saved, not just that the save count is right. That's the check that would have caught the in-flight-guard version dropping the edit, which was the sneakiest bug in the post. Would definitely read a part two on this.Good call on fake timers. That would make testing the 1.2s timeout much cleaner. And you're right, checking the call count alone isn't enough. That check could pass even with the broken in-flight guard, while the second edit gets dropped. The test should verify that "hello world" actually gets saved. Appreciate the Part 2 suggestion. I'll keep it in mind! 😊
Here's a wild thought - just use an AbortSignal on your fetch and then cancel it when calling your
searchmethod instead of this insane "lastId" systemThanks Momchil.
AbortControlleris definitely an option here. I considered it, but the point of the example was specifically to handle out-of-order responses, so I used the request ID to make that check explicit. Even if the earlier operation continues, its result still can't overwrite the latest one.Any screen that fires a request per keystroke has that race, and wifi hides it because responses come back in roughly the order you sent them. Good one to lead with.
The other thing throttling cannot give you is a slow CPU. I found my worst screen by opening the app on a real mid range phone, and it turned out to be the one I was most proud of.
Yeah, Eusebiu, that's the honest version of it. Any screen firing a request per keystroke can have that race sitting there. WiFi just happens to keep the timing close enough that you rarely see it.
The CPU point is a good one, and it's not something network throttling touches. DevTools can slow the network, but it doesn't reproduce what happens when the same JS work is running on a mid-range phone. A screen can look completely fine on my machine while the real bottleneck is work happening on the main thread. I haven't tested this on a real device yet, so that's a pretty useful gap you've pointed out.
Good debugging post as usual! 😄 API order and timing control are some of the hard parts of using APIs. Nice point!
Thank you so much! 😊 Yeah, API timing is one of those things that can look completely fine until you hit the right conditions to expose it. That's exactly what happened here. Really glad you liked the post!
the
latestQueryIdpattern is the version most teams skip past —AbortControllercleans up requests already in flight, but you still need the ID check if you want to handle the race between a cancel and the response landing. most teams reach for abort without realizing the full race surface.we hit the slower version of this in a typeahead: results were correct, but the loading spinner state was managed separately from the result state, so you'd get a flash of 'no results' between the stale request landing and the current one resolving. slow 3G is the only environment where that gap is wide enough to see.
do you use
AbortControlleras well, or just the ID guard?Mudassir, I used just the ID guard in the post. The search endpoint in the demo is cheap, so I didn't need to cancel anything server-side. I only needed to make sure a late response couldn't overwrite a newer one.
Using both makes sense, though. AbortController stops the client from waiting on the previous request, and the ID check makes sure a stale response can't update the UI if it still gets through.
The spinner case isn't something my demo covers, since it only tracks results and has no loading state. I can see how managing that separately could add its own timing issue.
The biggest lesson is that timing is not truth. A timeout does not mean something failed, and a response arriving later does not mean it represents the latest state.
Reliable systems need to track state explicitly instead of making assumptions based on timing.
Exactly, Vinny. That's the core of it. The search bug was a stale response landing after a newer one; the autosave bug was a timeout getting treated as a failed save when it hadn't failed at all. The network exposed the timing window, but the underlying problems were in my application's assumptions about timing and state.
Great experiment and learning.
Yes, DevTools catching these bugs maybe standard. On localhost, near-zero latency hides missing cancellation logic because requests finish sequentially. Throttling adds latency, allowing out-of-order responses to surface when users interact mid-request.
Have you tested with 3G traffic experience at lower layers? Your hardware and operating system, maybe even browser could be able to handle this and your app may not even face some of the scenarios. Just curious, I did network level tests for some apps maybe 2 decades ago and things I was discovering with app level simulations never occurred when I pushed the slowness simulation at network driver level as the network and OS handled many of the issues and my app then was surfacing the issues that it needed to handle. In other words I discovered the exact issues that app had to deal with and not others that network and OS would handle for me. I was on windows btw, and this was 2 decades ago.
Just curious, if this is an option
Good question. For this experiment, I used Chrome DevTools to throttle the network and deliberately tested how the application behaved when requests took longer to complete. That was enough to expose the two application-level issues I was looking for.
I haven't tested the same scenarios by introducing the network conditions at the network-driver level, so I can't say how the results would compare there. Your point about separating what the OS/network stack handles from what the application itself needs to handle is a useful distinction.
In this case, the two issues I found were still application-level problems: the search UI accepted an older response after a newer one had already been requested, and the autosave treated a client-side timeout as a failed save even though the server could still complete it.
I'd be interested to try the lower-level approach as a follow-up and see what changes. Thanks again, Debashish, for taking the time to read the post and share your experience. I really appreciate it.
The queueing fix is solid but it's all in-memory, so a page reload or a second tab mid-save loses the guard entirely and you're back to a version of bug two. Worth pairing it with an idempotency key generated client-side per edit and sent with the save, so even if two tabs or two retries both hit the server, the server can dedupe on that key instead of trusting request order or a saveInFlight flag that only exists in one tab's memory. Client-side sequencing catches the common case fast; server-side dedup catches the case where the client-side state itself gets wiped out from under it.
Mihai, fair point. The guard in the post lives in one page's memory, so a reload drops it, queued edit included, and another tab has its own copy.
An idempotency key covers the duplicate side. Generated per edit and reused on every retry, it lets the server drop a second write of the same edit. If the key only lives in memory, a reload loses it too, so it has to be persisted with the edit if retries need to survive a reload. It doesn't help when two tabs make separate edits, since each has its own key. That needs a version check on the server, or last write wins as a deliberate choice.
The post only shows the client-side version, so the server side is the natural next step.
Shubhra, this is such a good reminder that things can look perfectly fine on our own setup and still break somewhere else 😅 I liked the 3G test idea, especially how it exposed bugs that the tests never caught.
Thank you, Hema! 😊 That was exactly what I was hoping to uncover with the 3G test. Things looked fine on my usual setup, but slowing the connection down made those hidden assumptions much easier to spot.
Thanks for sharing, very insightful article!
Thank you, Cathy! I really appreciate you taking the time to read it and share this. Glad you found it useful!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.