Fetch coalescing + in-flight dedup + warm cache: turning a 37-fetch waterfall into a 684ms page
Fetch coalescing (a.k.a. request deduplication / single-flight on the read side) means: when N independent callers ask for the *same logical data* at roughly the same time, exactly one network call happens and all N callers resolve against that one call's result — instead of each caller firing its own request because none of them knows the others exist.
The signal that gives it away
- A perceived-load metric is wildly disproportionate to every other metric on the same page. In one observed case: time-to-first-byte 39ms (good), first-contentful-paint 720ms (good), total transfer 203KB (good), zero console errors — and Largest Contentful Paint at 29,184ms, roughly 11x over the "good" 2.5s threshold ([web.dev/lcp](https://web.dev/articles/lcp)). The page itself was fast; something late-arriving became the LCP element. That gap between "network waterfall looks fine" and "LCP looks catastrophic" is the tell that the LCP element is a widget waiting on something slow, not the page itself being heavy.
- Counting distinct network calls to the same logical endpoint within one page-load window reveals a multiple, not a one. A DevTools/Playwright trace of a single cold page load showed ~36-37 fetches for what should have been a handful of distinct data needs. Breaking it down by call site made the pattern obvious once isolated:
- one loop with no dedup guard fired 13 fetches — one per message that matched a "needs summary" predicate, even though many of those messages resolved to the same underlying step;
- one per-component mount hook fired 6 parallel fetches — 6 sibling components on the same screen, each independently loading "its" slice of the same underlying resource;
- a second per-component mount hook fired 7 parallel fetches — same shape, different resource;
- a polling/mount race left 3 orphaned fetches in flight with no way to cancel them, because nothing called AbortController.abort() on the previous attempt before starting a new one ([MDN, AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)).
- on top of the fetch waterfall, the widget that ended up being the LCP element was itself waiting on a synchronous LLM call (5-8s cold latency) with an empty slot in the DOM until the call resolved — so even after fixing the waterfall, the *last* mile of the LCP problem was "nothing renders while the request is pending," not "the app is still fetching too much."
- Multiple independent call sites solve the identical "give me the same thing" problem, unaware of each other. If you can point at more than one file/component that fetches the same conceptual resource (same session id, same target id, same underlying key) without any shared cache or store between them, you have this pattern whether or not you've measured the fetch count yet.
How to exploit it
The fix has three independent layers; they compose, and each one alone helps, but the full effect only shows up when you ship all three together.
Layer 1 — coalesce reads through a shared store with two maps, not a library.
Extend (or introduce) a store that every caller goes through instead of calling fetch directly:
- #cache: Map<key, {value, expiresAt}> — a short-TTL (30s in one implementation) in-memory cache. Enough to absorb the "6 siblings mount within the same tick" case without ever hitting the network twice.
- #inFlight: Map<key, Promise> — before firing a new request for key, check whether a request for that exact key is already in flight; if so, return the *existing* Promise instead of starting a second one. All N concurrent callers for the same key resolve off the one real network call. This is the read-side twin of the well-known singleflight pattern ([golang.org/x/sync/singleflight](https://pkg.go.dev/golang.org/x/sync/singleflight): "make sure that only one execution is in-flight for a given key at a time") — same idea, applied client-side to fetch instead of server-side to a function call.
- Wrap the pair in one method (fetchX(key)): check cache → check in-flight → else fire, store the Promise in #inFlight, and on settle move the result into #cache and delete the in-flight entry. ~30 LOC per method. No new dependency (a stale-while-revalidate library was deliberately skipped here — see trade-offs below).
- Invalidate on push, not just on TTL expiry. If the backend has a real-time channel (SSE/websocket-style event bus) that already announces "this underlying thing changed," wire it to delete the matching cache entry immediately, so the short TTL is a safety net for missed events, not the only invalidation mechanism.
- Measured result on the busiest call site: 22 calls → 6 calls (-73%) for one resource family, 7 → 6 (-14%) for a second, smaller one — the smaller gain there is a hint that dedup was already partial for that family before the fix (worth checking, not assuming, which call sites need the fix most).
Layer 2 — replace the empty slot with a visible skeleton while the slow thing resolves.
The waterfall fix alone does not remove the 5-8s the last, genuinely-slow call (an LLM call, in one instance) takes to resolve. What changes perceived performance here is rendering a placeholder in the exact final position the moment you *know* something is loading, instead of an empty slot that only gets replaced once data arrives:
- Fixed min-width on the placeholder equal to the real content's typical width, so the swap from placeholder → real content causes no layout shift.
- A subtle shimmer animation (a 1.4s cycle, low-contrast — rgba alpha 0.04→0.10 is a common choice) that respects prefers-reduced-motion.
- This is a pure perception change: the actual data arrives at the same wall-clock time as before. What moves is the user's read on whether the system is "working" vs. "dead."
Layer 3 — warm the cache in the background before the real consumer even mounts.
Once you have a server-side cache keyed the same way as the in-flight dedup (same key derivation), you can pay the slow cost early and off the critical path:
- As soon as the page/session has enough state to know what the "next" request will need (e.g. right after parsing the last completed message), fire the *exact same request* the real consumer will make later — fire-and-forget, no await blocking anything.
- The backend populates its own short-TTL cache (60s in one implementation, keyed by a hash of state + a slice of the triggering input) on that warm call.
- 1-2s later, when the component that actually needs the data mounts and issues what looks like a duplicate request, it hits the warm cache (~50ms) instead of paying the full 5-8s cold latency.
- Net measured effect across all three layers, same environment, same cold-load scenario: LCP 29,184ms → 684ms (-97.7%), comfortably inside the 2.5s "good" threshold; FCP unchanged (720ms → 684ms, it was never the problem); transfer -12% as a side effect of the dead-code cleanup below.
Layer 4 (cleanup, not optional) — cancel what you can't dedupe.
For the orphaned-poll case (a race between a mount effect and a "tick zero" poll firing overlapping requests), coalescing doesn't apply — the fix is a plain AbortController: keep a reference to the controller for the in-flight attempt, call .abort() on it before starting a new one, and release/cleanup the reference in the component's teardown. This stops the *previous* request from lingering; it does not, by itself, stop the *root* over-triggering (see below).
Layer 5 — audit for dead code once the coalescing store exists.
Once callers go through the shared store, whatever ad-hoc caching helpers they used to have (a per-component memoized-hash cache, a manual getCached/setCache pair) become dead weight. Removing ~150 LOC of orphaned helper functions was part of the same change in one case — they were not called anymore, but nothing had flagged that until the refactor made it visible. Treat this as a mandatory last step, not a nice-to-have: an unused local cache left in place is exactly the kind of thing that resurfaces as "wait, which cache is authoritative?" months later.
Related: Single-flight lock with Redis SET NX — reject or coalesce concurrent hits on the same mutation key is the server-side, write-path twin of the same idea (coalesce concurrent *mutations* on a key instead of concurrent *reads*) — combine both: dedupe on the client so you rarely even reach the server twice, lock/coalesce on the server so it's still safe when you do. A timer scheduled inside a reactive effect is cancelled by its own cleanup before it ever fires covers a sharp edge you'll hit while debouncing the trigger that decides *when* to fire the coalesced fetch: a naive debounce built on a reactive framework's effect-cleanup can cancel itself on every re-run before it ever fires. Check what you already own before paying again for the same external query is the same "read before you pay again" instinct applied to a metered external API instead of an internal endpoint.
What doesn't work
- Debouncing the trigger with a reactive-effect cleanup function that cancels the pending timer on every re-run. In a framework where effects re-run on every dependency change, attaching the debounce timer's cancellation to the effect's own cleanup means a dependency ticking again (which happens constantly in a chat-like UI) cancels the timer before it ever fires — the fetch that was supposed to happen 250ms later never happens at all, silently. The fix that held up: no cleanup function on that specific effect, and a separate explicit dedup key (built from the identity of what changed, e.g. sessionId|messageId|messageLength) checked at the top of the effect body instead of relying on cleanup semantics to prevent duplicate fires.
- Assuming the warm-cache key and the real consumer's key will always match. The whole trick depends on the warm fire-and-forget call and the real component's later call landing on the *identical* cache key. In one observed case the key was a hash of (pipeline state + a slice of the last message); a small variation in exactly which message/state snapshot fed the hash between the warm call and the real call (a plausible drift of a few hundred milliseconds of state) meant the two calls sometimes computed *different* keys — so instead of "1 real call, 1 cache hit," the system occasionally produced up to 4 calls total (warm + real, each potentially missing and re-populating a slightly different key). The net UX was still better (skeleton visible immediately, chip populated sooner on average), but the "exactly 1 network call" ideal requires the key derivation to be provably identical at both call sites, not just "close enough" — treat any state-hash-based key with suspicion until you've verified both call sites compute it from literally the same inputs at the same logical point in time.
- Treating `AbortController` as a fix for redundant triggering. Cancelling a stale in-flight request stops that *specific* request from lingering, but if the root cause is "three separate triggers fire on mount instead of one," aborting the first two still leaves three trigger events happening — you've just made each one individually harmless instead of removing the redundancy. The actual fix for over-triggering is the same dedup-by-key guard used for the fetch coalescing itself (layer 1), applied to the *trigger*, not a cancellation mechanism applied to its *side effect*.
- Removing an old client-side persistence layer (e.g. `localStorage`) as soon as an in-memory coalescing store exists, on the assumption it's now redundant. In one observed case a localStorage-backed cache had been left in place after the in-memory store shipped, and removing it (tried as a follow-up cleanup) reopened a brief flash-of-empty-state on page reload — because the in-memory store itself needs a moment to hydrate from the backend after a fresh page load, and the localStorage layer was covering exactly that ~1-2s bootstrap gap, not duplicating the 30s-TTL memory cache's job. Two cache layers with different jobs (bridge-the-reload-gap vs. absorb-concurrent-siblings-within-a-session) can both be legitimate at once; "redundant-looking" is not the same as "redundant."
- Reaching for a full stale-while-revalidate library as the first move. For a bounded key space (in one case, at most ~7 distinct keys per session), a Map-based cache + in-flight dedup covers the actual need in ~30 LOC with zero new dependencies. A SWR-style library becomes worth its complexity budget once the key space is large/unbounded (needs real eviction) or the revalidation policy needs to be more nuanced than "short TTL + push invalidation" — don't pay that cost up front for a small, bounded case.
Verified against
9 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.