Why your app feels flaky: dedup, retries, timeouts, and the queue
Individually, none of these four features sound exciting. Together, they’re the reason an app built on this pipeline keeps working on a flaky train wifi connection instead of showing five error toasts at once. This is the longest post in the series so far because these four things are genuinely tangled together — I’ll take them one at a time and then show how they interact.
Deduplication — collapsing identical in-flight requests
Problem: two components mount at the same time and both call
api.users.get('42'). Without dedup, that’s two network requests for
identical data, arriving at slightly different times, potentially racing
each other in your UI state.
Solution: the client tracks in-flight requests by their computed key (same shape of key used for caching). A second call with the same key while the first is still pending doesn’t fire a new request — it attaches to the same promise.
Component A ──▶ api.users.get('42') ──▶ fires request ──┐
Component B ──▶ api.users.get('42') ──▶ waits ─┼──▶ ONE network call
Component C ──▶ api.users.get('42') ──▶ waits ──┘
│
all three ◀───────┘
resolve together// opt a specific call out — rare, but useful for something like
// a manual "force refresh" button that should always hit the network
await api.users.get('42', { skipDedup: true })This is the exact same mechanism behind the OAuth2 refresh coalescing from the last post — many callers, one operation, shared result. Dedup is that pattern applied to ordinary GET requests instead of token refresh.
Retries with backoff — failing gracefully, not aggressively
Problem: a request fails because of a transient network blip or a 503
from an overloaded server. Fail immediately and you show the user an error
for something that would have worked half a second later. Retry
aggressively — no delay, no limit — and you can turn a struggling backend
into a dead one, hammering it exactly when it needs relief most.
Solution: configurable retry count with exponential backoff and jitter.
http: {
retry: {
attempts: 3,
backoff: 'exponential', // 200ms, 400ms, 800ms, roughly
retryOn: [408, 429, 500, 502, 503, 504],
},
}attempt 1 ──fail──▶ wait ~200ms
attempt 2 ──fail──▶ wait ~400ms
attempt 3 ──fail──▶ wait ~800ms
attempt 4 ──success──▶ returnretryOn matters as much as the count. Retrying a 400 Bad Request is
pointless — the request is malformed, and it’ll be malformed again in
200ms. Retrying a 429 Too Many Requests is genuinely useful. The default
list only includes status codes where retrying has a real chance of working.
Respecting Retry-After
Problem: a 429 response includes a Retry-After: 30 header, telling
you exactly how long to back off. Ignoring it and retrying on your own
exponential schedule anyway is actively rude to the server that just told
you not to — and at scale, across many clients doing the same thing, that’s
how a rate limiter turns into a denial-of-service against yourself.
Solution: when present, Retry-After overrides the computed backoff
delay for that retry. This is a small detail, but it’s the difference
between a client that cooperates with a server under load and one that makes
things worse.
Timeouts and cancellation — not letting a request hang forever
Problem: a request to a slow endpoint just… doesn’t return. No error, no data, the UI sits in a loading state indefinitely. Or: a user navigates away from a page while a request is still in flight, and the response comes back to a component that no longer exists.
Solution: every request gets a timeout (configurable at any config
layer, per part four), and every method’s ctx.request call respects an
AbortSignal passed through per-call config.
timeout: 10_000 // global default, overridable per module or per call// React: cancel the request if the component unmounts before it resolves
useEffect(() => {
const controller = new AbortController()
api.users.get(id, { signal: controller.signal }).then(setUser)
return () => controller.abort()
}, [id])Timeouts and manual cancellation are technically separate mechanisms — a
timeout is the client deciding “this has taken too long,” cancellation is
you deciding “I no longer care about this result” — but they resolve to
the same place in the pipeline: the in-flight request is aborted, and
nothing downstream (cache write, retry) happens for it. One caveat worth
knowing if you roll your own: AbortSignal.timeout() starts its clock the
moment you create it, so a signal built before the request reaches the queue
can expire while it is still waiting — I measured that failure in
your fetch timeout starts before the request does.
The concurrency queue — not opening fifty connections at once
Problem: a page renders a list of forty items, each firing its own detail request on mount. Forty simultaneous connections is a real problem — browsers cap concurrent connections per host (usually around six), so requests 7 through 40 just queue at the browser level anyway, invisibly, with no way for you to prioritize or reason about the order.
Solution: a configurable concurrency limit at the client level. Set it, and the pipeline queues requests beyond that limit itself — visibly, predictably — instead of leaving it to the browser’s opaque connection management.
createClient({
concurrency: { max: 6 },
})Requests 1–6 ──▶ dispatched immediately
Requests 7–40 ──▶ queued, dispatched as earlier ones completeHow these four features interact on one real request
Here’s the part that made me actually appreciate having all four in one pipeline instead of four separate hand-rolled utilities: they compose automatically, in the right order, without you thinking about it.
flowchart TD
A[40 requests fired at once] --> B[Concurrency queue:<br/>6 at a time]
B --> C{Duplicate key<br/>already in flight?}
C -->|Yes| D[Attach to existing promise]
C -->|No| E[Dispatch with timeout]
E --> F{Failed?}
F -->|Retryable status| G[Backoff, respecting<br/>Retry-After]
G --> E
F -->|No| H[Resolve, release queue slot]A page with forty duplicate-ish requests doesn’t turn into forty network calls, doesn’t overwhelm the browser’s connection limit, and doesn’t hammer a struggling backend during retries — and none of that required the component author to think about queues, dedup keys, or backoff math. That’s the actual point of the pipeline: these four features aren’t independent utilities you opt into one at a time, they’re properties of every request by default.
Real talk: I used to think retries were “free” reliability
Early on I set attempts: 5 everywhere, figuring more retries = more
reliable. What I actually got was requests taking up to fifteen seconds to
finally fail on a genuinely broken endpoint, because I hadn’t thought about
retryOn — I was retrying 404s and 401s that were never going to
succeed. Tune retryOn to your actual failure modes before you tune the
attempt count. Retrying the wrong thing more times just makes failure
slower, not less likely.
What sticks from this post
- Dedup collapses identical in-flight calls into one network request — same mechanism as the OAuth2 refresh coalescing from the auth post.
- Retries need a sane
retryOnlist more than they need a high attempt count — andRetry-Aftershould always override your own backoff math. - Timeouts and manual cancellation both abort the request, but for different reasons — one is the client giving up, the other is you no longer caring.
- The concurrency queue takes connection management out of the browser’s opaque hands and makes it something you can actually configure.
Next: multi-tenancy and multi-environment setups — how one client instance can safely serve requests for different tenants or point at different backends depending on context.
- developerehsan