DEV Community

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

Posted on Originally published at shubhra.dev AI-assisted

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

Exposes race conditions hidden by fast wifi

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.

Chrome DevTools Network panel with the throttling dropdown open, showing 3G selected between Slow 4G and Offline

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Browser showing the search demo with the fix off, apple's slow response overwrote banana's result even though banana was searched last

Terminal log confirming the server received apple first and banana second, but responded to banana first

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Same demo with the fix on, the stale apple response gets ignored and banana's result stays on screen

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
Enter fullscreen mode Exit fullscreen mode

Naive autosave demo showing repeated client-side timeouts and retries piling up before settling

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"
Enter fullscreen mode Exit fullscreen mode

In-flight guard demo where the textarea shows

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Coalescing fix demo, the queued edit fires right after the first save confirms, nothing dropped, nothing duplicated

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 (17)

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

Some comments may only be visible to logged-in visitors. Sign in to view all comments.