Five Components, One Request
In-flight coalescing and caching are two different mechanisms with two different windows. Confusing them is why "we have a cache" does not stop the thundering herd on mount.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Five components mount and each asks for the same user — how many requests should leave the browser, and which mechanism prevents the other four?
A person opens a dashboard. The avatar, the greeting, the permission-gated buttons and the account menu all need the current user. They expect one page, and they should be paying for one request.
Each component fetches what it needs. That is exactly what component independence is for: the avatar should not have to know that the account menu exists, and passing the user down through six layers of props is the thing components were supposed to save us from.
Five identical requests leave the browser in the same tick. On HTTP/1.1 they contend for the same small per-origin connection budget and delay everything else on the page (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
- Five identical requests leave the browser in the same tick. On HTTP/1.1 they contend for the same small per-origin connection budget and delay everything else on the page (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
- Five responses arrive and are parsed separately on the main thread, producing five copies of the same object and five renders (The Real Cost of JavaScript).
- Adding a cache does not fix it. On first mount there is nothing cached yet, all five requests miss simultaneously, and the cache is filled five times by five responses — the client-side shape of a stampede (Cache Stampede: Everyone Misses at Once in Backend).
- The five copies diverge. Each component now owns its own snapshot, so a mutation that updates one leaves the other four showing the old value (Server State Is Not Your State).
- It gets worse under a strict-mode double render, a route that mounts a layout twice, or a list that renders one component per row — the multiplier is not five, it is however many rows there are.
What is actually happening
In the browser, not in the framework.
- Coalescing operates on requests *in flight*. A registry maps a request key to the pending promise; a second caller with the same key gets the same promise rather than a second request. Its window is exactly the duration of one request.
- Caching operates on requests that have *completed*. A store maps a key to a result, with a notion of freshness. Its window is however long the entry is considered usable (The Client Cache Model).
- They are different mechanisms solving different halves of the same problem, and neither one covers the other. Coalescing without caching re-fetches on every mount; caching without coalescing lets the first N concurrent callers all miss.
- Both are keyed, and the key is the real design work. It must include everything that changes the answer — path, query, filters, sort, page, and often the identity of the current user (Query Keys and Invalidation).
- Coalescing shares one *outcome*, which means it also shares the failure. Five subscribers to one rejected promise get five errors from one request, which is correct — and five subscribers to one aborted promise must not all be told the request failed (Cancelling a Request Nobody Is Waiting For).
- This is the browser-side twin of what a server does in front of an expensive backend, and the vocabulary is shared: single-flight, request collapsing, coalescing (Request Coalescing in Backend).
What this makes the browser do
And which of it is avoidable.
- Connections: one instead of N, which frees the per-origin budget for requests that are not duplicates.
- Parsing: one
JSON.parseinstead of N over identical bytes. On a large payload this is the dominant saving and it lands on the main thread (Long Tasks). - Memory: one parsed object shared by reference, instead of N structurally identical objects with no shared identity — which also means one referential identity for memoisation to compare (Memoization).
- Renders: subscribers can be notified once from a single store update rather than each scheduling its own state change (What a Component Costs to Render).
- Avoidable: the registry itself. A map of in-flight promises is small, but it must be cleaned up on settle or it becomes a retention path (Memory Leaks).
One key, one request, many subscribers
The structure that fixes this is small: a key derived from the request, a cache of settled results, a registry of in-flight promises, and subscribers who read from the store rather than owning their own copy. Every component still asks for what it needs — the independence the naive version was protecting is preserved — but asking is now a subscription rather than a network call.
The important edge in the diagram is the one from the second, third and fourth callers into the *existing* promise. That edge is the entire mechanism, and it is the one a cache alone does not provide, because at that moment there is no settled result to return.
Two windows, two mechanisms
The clearest way to see that these are different things is to write down what happens on a cold mount with only one of them. The trace below is the same page load three times: with a cache only, with coalescing only, and with both. Each line is a component asking for the current user.
Read the middle block carefully. Coalescing alone gets first load right and then re-fetches on every remount, because nothing survives the settle. Caching alone gets remounts right and does nothing at all for the first load, because the cache is empty exactly when everyone asks. Neither is a superset of the other.
- Coalescing window — from request start to settle. Answers "somebody is already asking".
- Cache window — from settle to expiry or invalidation. Answers "somebody already asked".
- They abut. Between them there is no gap, and either alone leaves one open.
- The same key drives both, which is why key design is the real work (Query Keys and Invalidation).
CACHE ONLY (first load)
t0 Avatar cache miss → GET /api/user
t0 Greeting cache miss → GET /api/user
t0 AccountMenu cache miss → GET /api/user
t0 Permissions cache miss → GET /api/user
t1 4 responses, 4 parses, 4 writes of the same value
→ 4 requests. The cache filled 4 times and helped 0 times.
COALESCING ONLY (first load, then a remount)
t0 Avatar no pending → GET /api/user [registered]
t0 Greeting pending → subscribe
t0 AccountMenu pending → subscribe
t0 Permissions pending → subscribe
t1 1 response, 1 parse, 4 subscribers resolved ← the win
t1 settle → registry entry deleted
t9 navigate away and back
t9 Avatar no pending → GET /api/user ← nothing was kept
→ 2 requests. Correct on mount, useless on remount.
BOTH (first load, then a remount)
t0 Avatar cache miss, no pending → GET /api/user [registered]
t0 Greeting cache miss, pending → subscribe
t0 AccountMenu cache miss, pending → subscribe
t0 Permissions cache miss, pending → subscribe
t1 1 response, 1 parse → cache["user",id] = result, registry cleared
t9 navigate away and back
t9 Avatar cache hit (fresh) → 0 requests
→ 1 request. Coalescing covered the pending window,
the cache covered the window after it.The whole mechanism, and where to stop
Written out, the shared-request part is about fifteen lines. It is worth writing once by hand even if you then adopt a library, because the two subtleties — registering before the first await, and deleting in finally — are exactly the two things that go wrong, and they are much easier to see in fifteen lines than in a dependency.
The decision below is about scope rather than implementation. Not every duplicate is worth this machinery, and hoisting the request to a route loader removes both the duplication and a request waterfall at the same time, which a registry does not.
Several components need the same server data. Where should the sharing happen?
when The components are genuinely one unit in one tree, and the parent already exists.
cost Prop drilling, and it does nothing for siblings in other routes, portals, or a component rendered once per row (Prop Drilling, Context and Global State).
when The data is needed by the whole route, and you also want to remove a request waterfall.
cost Ties the data to navigation. A component added later that needs it must go through the route, and a partial refresh becomes a route concern (Route Loading Boundaries).
when The data changes constantly and caching it would be wrong, but the mount stampede is real.
cost Re-fetches on every remount. Correct, and often more network traffic than anyone expects.
when The default for server data of any consequence — which is most of it.
cost Key design, invalidation, eviction, and a session-scoped clear on sign-out. This is a real subsystem, and the reason data libraries exist (The Client Cache Model).
when Two cheap duplicate requests on a page that mounts once, on HTTP/2, with no correctness consequence.
cost It stops being true silently — the moment the component is rendered per row, the multiplier is the row count.
1const inflight = new Map<string, Promise<unknown>>()2const cache = new Map<string, { value: unknown; at: number }>()3 4export function query<T>(key: string, run: () => Promise<T>, maxAge: number): Promise<T> {5 const hit = cache.get(key)6 if (hit && Date.now() - hit.at < maxAge) return Promise.resolve(hit.value as T)7 8 const pending = inflight.get(key)9 if (pending) return pending as Promise<T> // ← the coalescing edge10 11 // Register synchronously. Any await before this line reopens the race:12 // two callers would both see an empty registry and both start a request.13 const p = run()14 .then((value) => { cache.set(key, { value, at: Date.now() }); return value })15 .finally(() => { inflight.delete(key) }) // ← or the key is poisoned forever16 17 inflight.set(key, p)18 return p19}Two lines carry the correctness. inflight.set runs in the same synchronous turn as the lookup, so no caller can slip between check and register. And finally — not then — clears the entry, so a rejection does not leave a permanently failing promise cached under a live key.
How to build it
Most important first.
- Key first. Decide what identifies a request before deciding what stores it — a stable, serialisable key derived from every input that changes the response (Query Keys and Invalidation).
- Coalesce and cache. Look in the cache; if fresh, return it. If not, look in the in-flight registry; if present, subscribe to it. Otherwise start one request and register it. Three lines of control flow, and they eliminate both duplicate classes.
- Delete the in-flight entry when the promise settles, in a
finally. A registry that retains settled promises has quietly become a cache with no expiry and no invalidation. - Share the *result*, not five copies of it. The point is one object with one identity, so that an update is one update (State Synchronization).
- Do not deduplicate mutations by default. Two clicks on Save may genuinely be two intentions; suppressing the second silently is a product decision, and the safe version of it is an idempotency key rather than client-side collapsing (Idempotency Keys: The Mechanism in API Design).
- Hoist the request where the components genuinely are one unit — a route loader is often the right answer, and it removes the waterfall as well as the duplication (Route Loading Boundaries).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Deduplication changes how many components are in a pending state at once, and therefore how many announce it. Five live regions each saying "Loading" is a considerably worse experience than one; consolidating the request should consolidate the announcement (Live Regions and Announcement).
- Announce once per user-visible operation, not once per subscriber. A screen-reader user experiences announcements serially, so duplicates cost real time.
- When a shared request fails, one
role="alert"— not one per component. Five simultaneous alerts is an interruption storm that makes the actual message unreachable. - Shared data means a shared moment of arrival, which makes it possible to place focus deliberately once. Five independent arrivals make focus management effectively impossible (Focus Management).
What can go wrong
- A key that is not stable: an object literal or an array rebuilt on every render produces a new key every time, so nothing ever matches and the deduplication is silently disabled.
- A key that is too coarse: two different filters collapse onto one entry and one component shows the other component's data. This is worse than no deduplication, because it is a correctness bug rather than a waste.
- A key that omits the user: after switching accounts, the previous account's data is served from the cache. This is a data-exposure bug with a performance optimisation as its cause (Authorization-Aware UI).
- Not clearing the in-flight registry on rejection, so a failed request permanently poisons the key and every subsequent caller gets the same old error.
- Cancelling the shared promise when one subscriber unmounts, cancelling it for the four who are still waiting. Reference-count subscribers, or do not cancel shared requests at all.
- Deduplicating a mutation, so a genuine second submission is dropped and the user is shown the first submission's result.
- The registry lookup and the registry write must not be separated by an
await. Two callers that both check "is there an in-flight request?" before either registers one will both start requests — the client-side check-then-act race (Race Conditions in Networking). - An invalidation can arrive while a coalesced request is in flight, so the response that everyone is waiting for is already known to be stale on arrival (Query Keys and Invalidation).
- A subscriber can join a promise that has already settled in the same tick, which is fine, and one can join a promise that is about to be aborted by a different subscriber, which is not (Cancelling a Request Nobody Is Waiting For).
- The cache key is an access control surface. Any key that does not include the identity of the current session can serve one user's data to another after a switch, in the same tab (What the Frontend Is Responsible For in Auth).
- Clear both cache and in-flight registry on sign-out. An in-flight request started while signed in will resolve after sign-out and populate a store the next user reads (Session Expiry and the Refresh Race).
- Shared state across components means one poisoned entry is visible everywhere at once. It raises the blast radius of any injection into the data path (Cross-Site Scripting).
- Deduplication is not a rate limit. It reduces requests you would have sent anyway; it does not defend a server from a client, which the server must do itself (Rate Limiting in Backend).
- "We have a cache, so we do not need coalescing." The cache is empty at exactly the moment all five components mount. The first-load stampede is precisely the case a cache cannot help with.
- "Deduplication is a performance optimisation." It is also a *consistency* mechanism: one shared result means one value on screen, and five copies means five values that drift apart (State Synchronization).
- "Debouncing does the same thing." Debouncing delays starting requests from one source. Coalescing joins requests from independent sources that all fired at once, which no delay can merge.
- "Two identical POSTs should be collapsed too." Two identical POSTs may be two intentions. Collapsing them client-side guesses; an idempotency key lets the server decide (Idempotency vs Deduplication in API Design).
- "Lifting the fetch into a parent solves it." It does for one tree. It does not for two sibling routes, a portal, or a component rendered per row.
Measuring it, and what changes in the field
- The Network panel on a cold load, filtered to XHR/fetch: count the identical URLs. Duplicates are self-evident once you look, and almost nobody looks (Debugging the Network).
- Count requests per page view in the field, not just latency. A regression that adds a duplicate request per row shows up as a request-count change long before it shows up as a latency change (Real User Monitoring).
- Server-side, a spike of identical requests from one session in one second is the same signal seen from the other end (API Metrics: Rate, Errors, Duration, Sizes in API Design).
- On HTTP/1.1, duplicates are expensive because of the connection budget. On HTTP/2 and HTTP/3 the marginal request is much cheaper, so the saving moves from the network to the main-thread parse (HTTP/2: Streams on One Connection in Networking).
- On a slow device, the parse and render duplication dominates: the network work was concurrent, the main-thread work was not.
- On a list that renders one component per row, the multiplier is the row count. This is where deduplication changes an application from unusable to fine (List Virtualization).
- In a long-lived tab, the in-flight registry and the cache both grow. Deduplication without eviction is a leak with a nice name (LRU Cache in DSA).
- Shared requests couple components that were deliberately independent. A change to the key in one place affects every subscriber, and a bad key is now a bug everywhere at once.
- Coalescing hides real concurrency: five callers now share one failure and one latency, so a slow request is slow for everyone and the tail is correlated rather than averaged (Tail Latency: Why p50 Being Fine Does Not Help in Observability).
- A registry and a cache are more machinery than
fetchin an effect, and the machinery is where the subtle bugs live — keys, eviction, cancellation reference counts. This is a large part of why data libraries exist.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe distinction between coalescing an in-flight request and caching a completed one is mechanism, not library: it holds for a hand-written promise registry, for a data library, and for a service worker sitting in front of the network (Intercepting Fetch).
- FRAMEWORK-SPECIFICWhat triggers the duplicate differs by framework. React's development strict mode deliberately double-invokes effects, so a duplicate that only appears in development is often that rather than a bug; Vue and Svelte do not, so the same code produces different request counts in the same browser.
- NETWORK-SPECIFICThe cost of a duplicate depends on the protocol: on HTTP/1.1 six concurrent requests per origin is the practical ceiling and duplicates block real work, while on HTTP/2 and HTTP/3 they are multiplexed and the cost moves almost entirely to the main-thread parse and to the server.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — a request registry is a single point of shared mutable state in an application otherwise built out of independent components, and it deserves the same scrutiny any other global gets.