Consider a dashboard layout where three separate components mount on the initial page load:
Each component owns its data requirements. When they mount, each component initiates an asynchronous fetch for /api/user/me.
If you rely solely on naive completed-result caching—storing the response only after it arrives—you encounter an immediate problem: all three components trigger before the first request finishes.
Because the cache is empty at the moment each component checks it, all three dispatch duplicate HTTP requests to the backend. While modern HTTP/2 and HTTP/3 allow multiple requests to share a connection (multiplexing streams over TCP or QUIC), you still waste server CPU, redundant database reads, and client memory.
To solve this, a frontend data layer must coordinate in-flight pending requests alongside stored results.
Scope and Limitations: The implementations in this article are minimal teaching examples focused specifically on request coordination and deduplication primitives. They omit automatic cache retention/eviction (expired entries remain in memory until overwritten or explicitly cleared), reactive subscriptions, retry backoff policies, and multi-tab session handling. Furthermore, preventing a stale cache write after invalidation does not prevent an existing in-flight caller from receiving the older result and updating its own local component state—components must still guard their local render lifecycle.
1. What Makes Two Requests Equivalent?
Before writing a single line of caching logic, you must decide what constitutes a cache key.
In a trivial prototype, developers often key by URL:
In a real application, raw string matching creates subtle bugs and collisions:
- 1.Authentication and Session Scope: If user A logs out and user B logs in without a full page reload, a key of
"/api/user/me"risks serving user A's cached profile to user B. Keys for user-scoped data must incorporate the active tenant or user identifier. - 2.Delimiter Collisions: A naive string join like
${scope}:${endpoint}collides when delimiters appear inside values. For example,["a:b", "c"]and["a", "b:c"]produce the exact same joined string"a:b:c". - 3.Query Parameter Ordering:
/api/search?page=1&sort=nameand/api/search?sort=name&page=1represent the same resource, but un-normalized query strings treat them as distinct cache misses. - 4.GraphQL and POST Requests: For GraphQL, an operation name and variables are not sufficient: you must also include the document query string (or query hash) and distinguish queries from mutations. Never automatically deduplicate arbitrary
POSToperations, asPOSTrequests are typically stateful mutations rather than idempotent reads.
To prevent delimiter collisions and order dependency, serialize keys using a JSON array structure with recursive property sorting:
2. A Minimal In-Flight Deduplicator
To prevent duplicate concurrent requests, the cache stores the running Promise in an active registry map. Subsequent callers request the same key, see an active Promise, and subscribe to it directly.
However, naive implementations of this pattern often harbor critical bugs:
- Synchronous errors in `fetcher`: If
fetcher()throws synchronously, execution must not settle before the promise is registered, otherwise the rejected promise could be stored permanently and prevent retries. - Cache clearing while a request is in flight: Calling
clear()orinvalidate()should prevent an older pending request from repopulating the cache after it settles. - Old requests settling after replacement: When a request is superseded or invalidated, its
finallyblock must not delete a newer replacement request's entry from the registry map. - Per-Key Invalidation: Invalidating one key must detach its pending request and invalidate its cache without suppressing writes for other, unrelated in-flight requests.
Here is a minimal teaching implementation in TypeScript demonstrating how to handle these lifecycle edge cases:
Key Mechanics to Notice
- 1.Deferred Execution via `Promise.resolve().then()`: This guarantees that
executionPromiseis registered inthis.inFlightbefore any part offetcher()executes. Iffetcher()throws synchronously, the error is handled as a microtask rejection;finallyruns after registration, safely cleans up via the identity check, and allows subsequent calls to retry. - 2.Per-Key Generation Guards:
invalidate(key)incrementsthis.keyGenerationsfor that key only. Any callers who were already awaiting the older in-flight request will still receive their promise result, but the request will be blocked from repopulating the cache. Crucially, in-flight requests for other keys continue undisturbed. - 3.Identity Check on Cleanup: In
finally,this.inFlight.get(key) === executionPromiseensures that ifinvalidate()was called and a replacement request was launched, the older settling request does not inadvertently delete the replacement request frominFlight.
3. Failure Handling and HTTP Status Codes
When writing a fetcher for use with a deduplicator, remember that the standard browser fetch() API does not reject on HTTP 4xx or 5xx status codes. It only rejects on low-level network failures, DNS lookup failures, or explicit request abortions.
If your fetcher does not check response.ok, an HTTP 500 error will resolve successfully, and the error response body will be committed into your cache as valid data!
When fetcher() rejects, the finally block cleans up the inFlight map via the identity check, and the error propagates to all components that subscribed to that shared Promise. The next time a component asks for that key, the cache will see a miss and retry the network call.
4. Shared Cancellation and Reference Counting
What happens if Component A initiates the shared request, but unmounts while the request is still in flight?
If Component A attaches its own AbortController and calls controller.abort() on unmount, it will abort the network request for Component B and Component C as well.
To support safe cancellation across shared callers, the deduplicator must manage the AbortController internally and maintain a subscriber reference count:
The Architectural Trade-Off
Aborting an upstream network request when subscribers.size === 0 saves bandwidth if a user navigates away. However, if your application experiences rapid tab switching, aborting can be counterproductive: letting the request finish and populating the cache means the next tab switch 200ms later is an instant cache hit rather than another cold fetch.
5. Freshness vs. Retention: SWR and Memory Lifecycles
In client-side caching discussions, two concepts are often conflated:
- 1.Freshness (`staleTime`): How long cached data is considered fresh. In libraries like TanStack Query,
staleTimeexpiration does not immediately schedule a background network request. Instead, it transitions the cached data into a "stale" state. Stale data remains in the cache and can continue to be rendered immediately; background revalidations are triggered by configured user interactions or lifecycle events—such as component remounting, window refocus, or network reconnect. - 2.Retention (`gcTime` or idle time): How long unreferenced cached data remains in memory after all components displaying it have unmounted. When this timer expires, data is garbage collected.
Appropriate Use of Cache Strategies
| Strategy | How It Operates | Responsible Use Case | Crucial Constraint |
|---|---|---|---|
| Cache-First | Returns cached data if fresh; only fetches on cache miss or expiration. | Static reference data, localization strings, documentation. | Not suitable for data that changes frequently on the server. |
| Stale-While-Revalidate (SWR) | Displays cached data immediately; revalidates on trigger events and updates the UI on arrival. | Activity feeds, user profiles, repository stars, dashboard metrics. | UI must handle background updates without disrupting user input or selection. |
| Network-First (with cached fallback) | Always tries the network first; falls back to cache on network failure. | Read-only offline previews of invoices or draft documents. | Never treat cached fallback data as authoritative permission for financial checkout, authorization, or inventory reservation. |
6. Bridging to the UI: Subscriptions and Reactive State
A Promise resolves once. But in a real web interface, data changes over time: background revalidations arrive, mutations invalidate keys, and tabs regain focus.
Sharing a Promise solves the initial mounting race, but it does not provide an ongoing reactive subscription. Once promise.then() executes, the component has no way to know if another component subsequently updated the cache.
To connect this cache to React or other UI frameworks, production libraries implement an Observer/Listener pattern:
In modern React (18 and 19), this is built using useSyncExternalStore, which subscribes directly to the cache's change event bus and re-renders components synchronously when cache contents update.
7. Putting It Into Practice: TanStack Query
Rather than hand-rolling a custom cache engine with subscriber buses and garbage collection in production, modern frontend teams use proven libraries like TanStack Query.
Here is how all the principles discussed—scoped keys, checked fetchers, abort signals, freshness, and retention—come together in practice:
8. Questions Your Design Should Answer
When designing or evaluating a client-side data layer, use this checklist of questions:
- [ ] Request Equivalency: Does your cache key account for user authentication ID, tenant context, and query parameter ordering without delimiter collisions?
- [ ] In-Flight Deduplication: If three components mount simultaneously, does your code share the pending Promise rather than dispatching three requests?
- [ ] Failure Resilience: Does your fetcher check
response.okbefore returning? Does the in-flight registry clean up properly on both synchronous errors and rejections? - [ ] Superseded Requests: If an old request settles after an invalidation or after a newer request has started, does your code prevent the old request from overwriting newer cache data?
- [ ] Targeted Invalidation: Does invalidating one key detach its pending request without suppressing cache writes for other, unrelated in-flight requests?
- [ ] Safe Cancellation: If one component unmounts and aborts, does your architecture preserve the in-flight request for other mounted subscribers?
- [ ] Freshness vs. Retention: Does your design distinguish between when data is stale (
staleTime) versus when unused memory should be garbage collected (gcTime)? - [ ] Server Authoritativeness: Do you avoid treating cached offline data as authoritative approval for transactions or access control?