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...
For further actions, you may consider blocking this person and/or reporting abuse
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.
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.
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.
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!
Nice write-up shubhra!! :D
Thank you so much, Divya! 😊 Really glad you liked it!
Throttling ở layer network (Chrome DevTools,
tc, Clumsy) chỉ mô phỏng latency + bandwidth. Nó không bắt được:Test thực tế: bật
netemloss 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)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.
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)
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
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
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.
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
AbortControllervớ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 .techThự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:
useEffectmount sau hydration.Tip nhỏ: thêm
AbortControllergắn vàofetch/axiosvới timeout ngắn hơn gateway, logperformance.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ớinetworkConditions: 'Slow 3G'— không thay thế được prod monitoring nhưng bắt được regression sớm — found it via LabAgent, site: labagent .tech