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

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