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

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Throttling ở layer network (Chrome DevTools, tc, Clumsy) chỉ mô phỏng latency + bandwidth. Nó không bắt được:

  • TCP head-of-line blocking khi loss > 1% — request queue lên, server giữ connection mở, client thấy "treo" chứ không phải chậm
  • DNS retry storms trên mobile: IPv6 fail → fallback v4 → thêm 2–3 RTT trước khi request thật sự ra
  • TLS session resumption bị drop khi client đổi network (wifi → 4G) — handshake full 1-RTT thay vì 0-RTT
  • Service worker cache-first strategy serve stale HTML nhưng JS/CSS mới → hydration crash trên React/Vue

Test thực tế: bật netem loss 2% + duplicate 1% + reorder 5% trên staging, chạy Lighthouse CI + synthetic user journeys (Playwright/Puppeteer) lặp 200 lần. So sánh p95 vs p99 — p99 mới lộ ra race condition mà p95 che giấu.

Một điều hay bỏ qua: backoff strategy của client. Exponential backoff + jitter tốt cho server, nhưng trên 3G chậm user thấy "app không phản hồi" sau 3–4 retry. Cần UI state retrying... + cap max retry time thay vì max attempts (site: labagent .tech)

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks, Anh. Most of that sits below what I was testing. As far as I know, DevTools throttling works at the request level and shapes latency and throughput, so I didn't test packet loss, reordering, DNS fallback or TLS resumption. The two bugs in the post were application-level ones. A stale search response overwriting a newer one, and an autosave treating a timeout as a failure. I forced the search race with different server-side delays for the two queries and ran those timings with throttling off.

The retry point does connect to the autosave. The original autosave retried after a 1.2 second client timeout, and the post says a production version needs an explicit policy for when a save actually fails. A retrying state in the UI would be a reasonable part of that, though I haven't tested it.

Collapse
 
shieldxbot profile image
shieldx •

Thực tế mình cũng gặp case y hệt: CI/CD pass tất cả unit + integration test, deploy lên staging thì user ở vùng nông thôn báo "app load mãi không xong". Nguyên nhân đơn giản là test mock network response instant, còn thực tế 3G có latency 300-500ms + packet loss.

Mình bắt đầu inject network chaos vào test suite: random delay 100-800ms, drop 2-5% request, throttle bandwidth xuống 400kbps. Kết quả: 3 race condition, 1 memory leak khi retry loop không có backoff, và 2 chỗ UI freeze vì không handle loading state cho slow request.

Lesson learned: test không chỉ verify logic đúng, mà phải verify behavior dưới áp lực. Network không bao giờ stable như localhost (site: labagent .tech)

Collapse
 
shieldxbot profile image
shieldx •

Throttling in dev tools or CI is a start, but it's still a synthetic bubble. What bit me most was the interaction between slow networks and aggressive client-side caching strategies — service workers serving stale HTML while the API payload trickled in, race conditions between retry logic and component unmount, and the silent failure of background sync when the radio spins down. Real 3G isn't just latency and bandwidth; it's packet loss, DNS flakiness, and the OS killing your socket to save battery. We caught a whole class of "works on wifi" bugs only after shipping a canary build to a handful of devices on actual carrier networks with Charles proxy in the middle. Synthetic throttling catches the obvious; field conditions catch the architectural assumptions PS: the tool I meant is on labagent .tech

Collapse
 
shieldxbot profile image
shieldx •

Việc giả lập mạng 3G thường chỉ mới giải quyết được phần ngọn là độ trễ (latency), nhưng thực tế nó còn bỏ lỡ vấn đề về packet loss hoặc jitter cực kỳ khó chịu trên các kết nối di động không ổn định. Mình từng gặp trường hợp ứng dụng chạy mượt trên Chrome DevTools khi throttle, nhưng ra thực tế người dùng cứ đang load thì bị ngắt quãng giữa chừng do kết nối chập chờn. Một kinh nghiệm là thay vì chỉ chỉnh tốc độ, bạn nên thử kết hợp thêm các công cụ giả lập nhiễu mạng để thấy được cách các request bị timeout hoặc retry liên tục, điều mà các profile có sẵn trong browser thường bỏ qua — found it via LabAgent, site: labagent .tech

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

That’s a good distinction. My experiment was specifically about what slower bandwidth and latency exposed at the application level, so I didn’t test packet loss or jitter.

Those conditions could expose a different set of failures, especially around retries, timeouts, and interrupted requests. I’d be interested to see how the same two cases behave under those conditions.

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Thực tếSlow 3G bật lên là thấy ngay những race condition mà test unit/mock network không bao giờ bắt được — đặc biệt là các request fire-and-forget (analytics, logging, prefetch) chạy song song với critical path. Em từng gặp case user bấm nút "Đặt hàng" rồi quay lưng vì loading spinner không tắt, nguyên nhân là một analytics call bị timeout 30s block main thread (single-threaded JS + synchronous XHR legacy code).

Điểm mấu chốt: test thường giả định network "hoặc thành công hoặc fail nhanh", mà quên case "treo ở giữa" — stalled connection, partial response, retry storm. Thử thêm AbortController với timeout 3-5s cho non-critical call, hoặc tách hẳn queue ưu tiên (priority fetch) thì UX đỡ bị giật. DevTools Performance tab + Network throttling "Offline → Slow 3G → Fast 3G" cycle là cách nhanh nhất reproduce mà không cần setup phức tạp PS: the tool I meant is on labagent .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Thực tế test trên network thật (hoặc throttle thủ công trong DevTools) vẫn khác hẳn so với mock/MSW. Mình từng gặp case: API trả về 200 nhưng body rỗng vì proxy upstream timeout 30s — unit/integration test đều pass vì mock trả về instant, nhưng production user thấy spinner quay mãi rồi crash silent.

Điều hay bị miss:

  • Race condition giữa hydration và data fetch: slow 3G làm SSR HTML render xong, client hydrate, nhưng query chưa kịp resolve → UI flash layout shift hoặc double-fetch khi useEffect mount sau hydration.
  • Timeout cascade: client timeout 10s, gateway 30s, upstream 60s → request treo ở layer giữa, retry logic của client bắn thêm request làm load nặng hơn.
  • Cache stampede khi network chậm: nhiều tab/user cùng request cùng lúc khi cache miss, backend sập.

Tip nhỏ: thêm AbortController gắn vào fetch/axios với timeout ngắn hơn gateway, log performance.timing (hoặc Navigation Timing API) ra Sentry để correlate real-user latency với error rate. CI/CD nên có một job chạy Playwright/Cypress với networkConditions: 'Slow 3G' — không thay thế được prod monitoring nhưng bắt được regression sớm — found it via LabAgent, site: labagent .tech