DEV Community

Cover image for I Throttled My App to Slow 3G. Here's What My Tests Never Caught

I Throttled My App to Slow 3G. Here's What My Tests Never Caught

Shubhra Pokhariya on September 15, 2026

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...
Collapse
 
systemcraftdev profile image
SystemCraftDev •

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.mock delay, Playwright's route.fulfill with 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: mock postSave with 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.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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.

Collapse
 
systemcraftdev profile image
SystemCraftDev •

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.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya •

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! 😊

Collapse
 
fristys profile image
Momchil Georgiev •

Here's a wild thought - just use an AbortSignal on your fetch and then cancel it when calling your search method instead of this insane "lastId" system

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks Momchil. AbortController is 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.

Collapse
 
beusebiu profile image
Eusebiu Balan •

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.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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.

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper •

Good debugging post as usual! 😄 API order and timing control are some of the hard parts of using APIs. Nice point!

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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!

Collapse
 
mudassirworks profile image
Mudassir Khan •

the latestQueryId pattern is the version most teams skip past — AbortController cleans 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 AbortController as well, or just the ID guard?

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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.

Collapse
 
phantom-byte profile image
Vinny Barreca •

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.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal •

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

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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.

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

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.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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.

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala •

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.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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.

Collapse
 
cathylai profile image
Cathy Lai •

Thanks for sharing, very insightful article!

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thank you, Cathy! I really appreciate you taking the time to read it and share this. Glad you found it useful!

Collapse
 
technogamerz profile image
𝐓𝐡𝐞 𝐋𝐚𝐳𝐲 𝐆𝐢𝐫𝐥 •

Nice write-up shubhra!! :D

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thank you so much, Divya! 😊 Really glad you liked it!