Skip to main content

Client Cache Design

Editable diagram: Open in Excalidraw

High-level architecture

1. Problem Statement​

Design a browser-side client library for a React application that reads and updates server data while:

  • caching read results on the client
  • avoiding repeated requests for the same data
  • supporting filtered and paginated lists
  • keeping cached data correct after mutations
  • preventing browser memory growth
  • integrating cleanly with server-side HTTP caching

The server remains the source of truth.


2. Staff-Level Framing​

A Staff Engineer should focus less on Map syntax and more on:

  1. cache ownership and boundaries
  2. cache-key correctness
  3. read freshness semantics
  4. mutation and invalidation strategy
  5. filtered-list correctness
  6. race conditions
  7. request deduplication
  8. memory bounds and garbage collection
  9. browser HTTP cache / ETag integration
  10. persistence choices
  11. multi-tab consistency
  12. security and tenant isolation
  13. observability
  14. testing and rollout strategy

A strong opening statement:

I would separate application caching from browser HTTP caching. The client library owns query freshness, deduplication, subscriptions, optimistic updates, and invalidation. The browser HTTP cache handles transport reuse through Cache-Control and ETag. The most important correctness problems are cache-key construction, mutation/list reconciliation, and stale responses overwriting newer data.


3. Functional Requirements​

  • Read resources from the server.
  • Cache successful GET/query results in memory.
  • Support deterministic query keys.
  • Include filters, sort, pagination, tenant/user scope, locale, permissions, and API version in cache identity where relevant.
  • Return fresh cached data without another network call.
  • Support TTL / stale time.
  • Support stale-while-revalidate.
  • Deduplicate simultaneous identical requests.
  • Support filtered and paginated lists.
  • Support create/update/delete mutations.
  • Update or invalidate affected cached results after mutation.
  • Support optimistic updates with rollback.
  • Prevent stale responses from overwriting newer state.
  • Support manual invalidation by key, prefix, or tag.
  • Support React subscriptions.
  • Optionally persist selected data to IndexedDB.
  • Optionally support cross-tab invalidation.
  • Integrate with HTTP Cache-Control and ETag.

4. Non-Functional Requirements​

Latency​

  • Memory-cache hits should be near-memory speed.
  • Avoid unnecessary network round trips.

Correctness​

  • Never return data from the wrong user or tenant.
  • Mutations must not be overwritten by older reads.
  • Filtered lists must remain semantically correct.

Bounded resource usage​

  • Maximum entry count.
  • Maximum approximate byte size.
  • LRU or equivalent eviction.
  • Garbage collection after unused queries become inactive.

Reliability​

  • Handle timeouts and retries.
  • Support stale-if-error where acceptable.
  • Do not cache transient server failures.

Performance​

  • Avoid main-thread blocking.
  • Avoid using localStorage for large API caches.
  • Avoid re-rendering the whole React tree when one record changes.

Observability​

Track:

  • cache hit rate
  • stale hit rate
  • miss rate
  • cache bytes
  • entry count
  • eviction count
  • deduplicated request count
  • background refetch count
  • mutation rollback count
  • backend request reduction
  • P50 / P95 / P99 latency

5. High-Level Architecture​

┌──────────────────────────── Browser ────────────────────────────┐
│ │
│ React Components │
│ │ │
│ v │
│ useQuery / useMutation │
│ │ │
│ v │
│ ┌──────────────────────────────────────────┐ │
│ │ DataClient │ │
│ │ │ │
│ │ Query Cache Inflight Map │ │
│ │ key -> entry key -> Promise │ │
│ │ │ │
│ │ Entity Store Subscription Manager │ │
│ │ TTL / SWR Invalidation │ │
│ │ LRU / GC Mutation Coordinator │ │
│ └──────────────┬───────────────────────────┘ │
│ │ │
│ optional persistence │
│ │ │
│ IndexedDB │
│ │
│ BroadcastChannel <---------------> other tabs │
│ │
└──────────────────────────┬────────────────────────────────────┘
│
fetch()
│
v
Browser HTTP Cache
Cache-Control / ETag
│
v
Server
│
v
DB

6. Cache Ownership​

Use the right layer for the right problem.

LayerResponsibility
In-memory query cacheHot runtime server data
Normalized entity cacheShared canonical entities
IndexedDBPersistent/offline structured data
localStorageSmall preferences only
Browser HTTP cacheHTTP response reuse and revalidation
CDN/server cacheShared server-side transport caching

Recommended default:

Hot API responses -> memory
Persistent/offline data -> IndexedDB
Tiny preferences -> localStorage
HTTP GET reuse -> Cache-Control + ETag

7. Cache Key Design​

A query result is correct only if the cache key fully represents everything that changes the response.

Example:

[
'users',
{
teamId: 42,
status: 'active',
sort: 'name',
cursor: 'abc',
tenantId: 't1',
locale: 'en-US',
},
];

Relevant dimensions may include:

resource
filters
sort
pagination/cursor
search term
tenant
logged-in user
permissions
locale
feature flags
API version

Canonicalization matters.

These must become the same key:

{ teamId: 42, status: "active" }
{ status: "active", teamId: 42 }

Bad cache-key design can cause both correctness bugs and cross-tenant data leakage.


8. Basic Data Structures​

interface QueryEntry<T> {
data?: T;
error?: Error;
updatedAt: number;
staleAt: number;
gcAt?: number;
generation: number;
subscribers: Set<() => void>;
}

class DataClient {
private cache = new Map<string, QueryEntry<unknown>>();
private inflight = new Map<string, Promise<unknown>>();
}

For normalized caching:

const entities = new Map<string, unknown>();
const queries = new Map<string, string[]>();

Example:

Entity Store
User:1 -> { id:1, name:"Alice" }
User:2 -> { id:2, name:"Bob" }

Query Store
users:active -> ["User:1", "User:2"]
team:42 -> ["User:2"]

9. Read Path​

query(key)
|
v
fresh cache?
/ \
yes no
| |
return in-flight request?
/ \
yes no
| |
reuse fetch
Promise |
v
server
|
v
store result

Core algorithm:

async function query<T>({
key,
fetcher,
staleTime = 60_000,
}: {
key: unknown[];
fetcher: () => Promise<T>;
staleTime?: number;
}): Promise<T> {
const cacheKey = hashKey(key);
const now = Date.now();
const cached = cache.get(cacheKey) as QueryEntry<T> | undefined;

if (cached?.data !== undefined && now < cached.staleAt) {
return cached.data;
}

const existing = inflight.get(cacheKey);
if (existing) {
return existing as Promise<T>;
}

const request = fetcher()
.then((data) => {
cache.set(cacheKey, {
data,
updatedAt: Date.now(),
staleAt: Date.now() + staleTime,
generation: cached?.generation ?? 0,
subscribers: cached?.subscribers ?? new Set(),
});

notify(cacheKey);
return data;
})
.finally(() => {
inflight.delete(cacheKey);
});

inflight.set(cacheKey, request);
return request;
}

10. Request Deduplication​

Without request coalescing:

Component A --> GET /users/123
Component B --> GET /users/123
Component C --> GET /users/123

With an in-flight map:

Component A ---+
Component B ---+----> one Promise ----> one HTTP request
Component C ---+

Data structure:

Map<queryKey, Promise<Response>>;

This solves duplicate requests occurring at the same time.

It is different from TTL caching, which prevents repeated requests over time.


11. Fresh, Stale, and Garbage-Collected​

Separate freshness from retention.

0 ---------------- 1m ------------------------ 5m
fresh stale GC

Example:

staleTime = 1 minute
gcTime = 5 minutes

Fresh​

Return cached value immediately with no network request.

Stale​

Optionally return cached data immediately and refresh in the background.

Garbage collected​

Remove the entry after it has been unused long enough.

Staff-level point:

TTL determines freshness. GC/LRU determines resource retention. They are different policies.


12. Stale-While-Revalidate​

request
|
v
stale cache exists
|
+----------> return stale value immediately
|
+----------> background network refresh
|
v
newer value
|
v
notify UI

Useful when:

  • slight staleness is acceptable
  • latency matters
  • data changes moderately often
  • backend calls are expensive

Avoid for strongly consistent or sensitive workflows unless explicitly allowed.


13. GET List Caching​

Example:

GET /users?status=active
GET /users?status=inactive

Cache keys:

users:{status:active}
users:{status:inactive}

Simple cache:

cache.set('users:active', [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);

For large systems, prefer normalized storage:

Entity Store
User:1 -> Alice
User:2 -> Bob

Query Store
users:active -> [1,2]

This avoids duplicating full objects across many filtered lists.


14. Mutation Strategy​

There are three main strategies.

A. Invalidate and refetch​

PATCH user
|
success
|
invalidate affected queries
|
next read/refetch gets canonical server data

Best when:

  • server owns filtering/ranking
  • pagination is complex
  • correctness matters more than avoiding a refetch

B. Update cache from server response​

const updated = await api.updateUser(id, patch);
client.set(['user', id], updated);

Best when:

  • mutation response contains the canonical updated entity
  • membership in related lists does not change

C. Optimistic update​

const previous = client.get(['user', id]);

client.set(['user', id], {
...previous,
...patch,
});

try {
const actual = await api.updateUser(id, patch);
client.set(['user', id], actual);
} catch (err) {
client.set(['user', id], previous);
throw err;
}

Best when:

  • user interaction should feel instant
  • rollback semantics are clear

15. Updating One Item in a Cached List​

Simple case: a display field changes.

Before:

activeUsers -> [
{ id:1, name:"Alice" },
{ id:2, name:"Bob" }
]

Mutation:

Bob.name -> Bobby

Safe local patch:

function updateCachedList(list, updatedUser) {
return list.map((user) => (user.id === updatedUser.id ? updatedUser : user));
}

16. Hard Case: Mutation Changes Filter Membership​

Before:

active -> [1,2]
inactive -> [3]

Mutation:

User:2 status active -> inactive

Correct result:

active -> [1]
inactive -> [2,3]

Two strategies:

Local reconciliation​

Use when filter logic is simple and client-owned.

function matchesFilter(user, filter) {
if (filter.status && user.status !== filter.status) {
return false;
}

return true;
}

Invalidate and refetch​

Use when list membership depends on:

  • server-side ranking
  • complex filters
  • pagination/cursors
  • permissions
  • full-text search
  • machine-learning ranking
  • server-only business rules

Staff-level statement:

If membership is simple and deterministic, I can reconcile locally. If query semantics are server-owned, I invalidate affected query results instead of duplicating backend business logic in the browser.


17. Normalized Entity Cache​

Apollo/Relay-style normalized caching can reduce duplication.

Instead of:

activeUsers -> [full User 1, full User 2]
teamUsers -> [full User 2, full User 3]
search -> [full User 2]

Use:

Entity Store
User:1 -> object
User:2 -> object
User:3 -> object

Query Store
activeUsers -> [1,2]
teamUsers -> [2,3]
search -> [2]

Updating User:2 changes one canonical object.

But normalized caching does not automatically solve filtered-list membership. Query/index references may still need reconciliation or invalidation.


18. Race Condition: Old GET Overwrites New Mutation​

Critical Staff-level deep dive.

T1 GET user starts
|
| old response contains Alice
|
T2 PATCH user -> Bob
|
cache = Bob
|
T1 returns Alice
|
cache = Alice ❌

Solution: generation/version​

const generations = new Map<string, number>();

Before starting request:

const generation = generations.get(key) ?? 0;

After response:

const result = await fetcher();

if ((generations.get(key) ?? 0) === generation) {
cache.set(key, result);
}

Mutation/invalidation:

generations.set(key, (generations.get(key) ?? 0) + 1);

Alternative: server entity version.

cache version = 18
incoming response version = 17

17 < 18 -> discard

19. Cancellation and Shared Requests​

Suppose:

Component A ---+
Component B ---+--> same request
Component C ---+

If A unmounts, do not automatically abort the network request because B and C still need it.

Model:

interface InflightRequest<T> {
promise: Promise<T>;
controller: AbortController;
subscribers: number;
}

Abort only when:

subscribers === 0

20. Memory Safety​

Never rely on TTL alone.

Example failure:

TTL = 1 hour
1,000,000 unique query keys

Even with TTL, the browser can run out of memory before expiration.

Use:

maxEntries
maxBytes
LRU
GC
do-not-cache threshold
pagination
normalization

Strong interview line:

TTL is a freshness policy; LRU/max-size is a memory policy. A production browser cache needs both.


21. LRU Eviction​

Conceptual structure:

MRU
|
v
[A] <-> [D] <-> [B] <-> [C]
^
|
LRU

When capacity is exceeded, evict the least recently used entry.

Typical target:

HashMap + doubly linked list
get O(1)
set O(1)
evict O(1)

For a simpler browser implementation, Map insertion order can approximate LRU.


22. Byte-Based Bounds​

Entry count alone is insufficient.

500 tiny records != 500 large documents

Track approximate size:

interface CacheEntry<T> {
data: T;
sizeBytes: number;
}

Example policy:

maxEntries = 500
maxBytes = 20 MB

Avoid memory caching:

  • large images
  • video
  • large binary payloads
  • huge unpaginated tables
  • multi-megabyte documents unless necessary

23. localStorage vs IndexedDB vs HTTP Cache​

localStorage​

Best for:

  • theme
  • locale
  • tiny preferences
  • selected workspace

Avoid for large API caches because:

  • synchronous
  • string-only
  • JSON serialization cost
  • can block main thread

IndexedDB​

Best for:

  • offline-first data
  • large structured data
  • persistent query cache
  • documents/messages/metadata

Advantages:

  • asynchronous
  • structured objects
  • larger capacity
  • transactions and indexes

HTTP cache + ETag​

Best for:

  • network-response reuse
  • bandwidth reduction
  • validation against server source of truth

They are complementary.


24. ETag and Browser HTTP Cache​

Server response:

HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "user-123-v17"

After the response becomes stale:

GET /api/users/123
If-None-Match: "user-123-v17"

If unchanged:

HTTP/1.1 304 Not Modified

If changed:

HTTP/1.1 200 OK
ETag: "user-123-v18"

Staff-level distinction:

Application cache
= query state
= subscriptions
= mutation consistency
= dedupe
= optimistic updates

HTTP cache / ETag
= network reuse
= bandwidth reduction
= server validation

Do not treat ETag as a replacement for the application cache.


25. Example Server ETag Implementation​

app.get('/api/users/:id', async (req, res) => {
const user = await db.getUser(req.params.id);

const etag = `"user-${user.id}-v${user.version}"`;

res.setHeader('Cache-Control', 'private, max-age=60');

res.setHeader('ETag', etag);

if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}

res.json(user);
});

Frontend:

const response = await fetch('/api/users/123');
const user = await response.json();

The browser handles normal HTTP caching behavior automatically.


26. Cross-Tab Consistency​

Problem:

Tab A updates User:2
Tab B still has stale User:2 in memory

Use BroadcastChannel when required.

const channel = new BroadcastChannel('query-cache');

channel.postMessage({
type: 'INVALIDATE',
key: ['user', '2'],
});

Other tabs:

channel.onmessage = (event) => {
if (event.data.type === 'INVALIDATE') {
client.invalidate(event.data.key);
}
};

27. Retry and Failure Handling​

Retry transient failures such as:

network timeout
connection reset
429
502
503
504

Usually do not automatically retry:

400
401
403
validation errors

Use exponential backoff with jitter.

100ms
250ms
500ms
1100ms
...

Do not cache transient 500 responses as normal data.

Optional short negative caching can be useful for stable 404 results.


28. Stale-if-Error​

For non-critical data, stale data may be safer than an empty UI during a temporary outage.

cache stale
|
refresh fails
|
serve stale value
|
show subtle stale/error state if needed

This must be a product/business decision, not an accidental cache behavior.


29. React Integration​

Do not put the entire cache object into Context state.

Better:

const DataClientContext = createContext<DataClient | null>(null);

function DataClientProvider({ children }) {
const client = useMemo(() => new DataClient(), []);

return <DataClientContext.Provider value={client}>{children}</DataClientContext.Provider>;
}

Context distributes the stable client instance.

Each component subscribes only to the query it uses.

function useQuery(options) {
const client = useDataClient();

return useSyncExternalStore(
(listener) => client.subscribe(options.key, listener),
() => client.getSnapshot(options.key)
);
}

This avoids rerendering the whole application whenever one cache entry changes.


30. Security and Isolation​

Cache correctness is also a security problem.

Include appropriate scope in cache identity:

tenant
user
permissions
locale
API version

Important rules:

  • clear user-specific cache on logout
  • clear or partition cache on account switch
  • never reuse tenant-scoped results across tenants
  • do not persist sensitive data unnecessarily
  • avoid storing auth tokens in localStorage by default
  • use Cache-Control: private for user-specific HTTP responses

31. Observability​

Minimum metrics:

cache_hit
cache_miss
stale_hit
cache_entry_count
cache_bytes
cache_eviction
request_deduplicated
background_refetch
mutation_success
mutation_rollback
retry_count
backend_latency

Derived metrics:

hit rate = hits / (hits + misses)

But do not optimize for hit rate alone.

Also measure:

  • user-visible latency
  • memory use
  • backend QPS reduction
  • stale-read rate
  • error rate

32. Testing Strategy​

Unit tests​

  • deterministic query-key serialization
  • TTL expiration
  • stale-while-revalidate
  • LRU eviction
  • byte-limit eviction
  • entity normalization
  • filter membership logic

Concurrency tests​

  • two identical reads create one network request
  • mutation while read is in flight
  • stale request cannot overwrite newer mutation
  • cancellation with multiple subscribers

Integration tests​

  • mutation invalidates filtered lists
  • optimistic rollback
  • tenant switch clears scoped cache
  • ETag 304 behavior
  • IndexedDB hydration
  • cross-tab invalidation

Performance tests​

  • thousands of cache entries
  • large lists
  • repeated navigation
  • memory growth over time
  • rerender counts

33. Evolution Strategy​

Do not overbuild V1.

V1​

query-key cache
TTL
request dedupe
manual invalidation

V2​

stale-while-revalidate
optimistic mutation
LRU / GC

V3​

normalized entities
IndexedDB persistence
cross-tab sync

V4​

advanced dependency tracking
server-driven invalidation
push updates / subscriptions
fine-grained cache policies

34. Staff-Level Tradeoff Summary​

Query result cache vs normalized entity cache​

Use query result caching when:

  • application is small
  • responses are not heavily duplicated
  • simple invalidation is acceptable

Use normalization when:

  • many views share the same entities
  • large lists duplicate objects
  • one entity update should propagate widely

Local patch vs invalidate/refetch​

Patch locally when:

  • mutation result is canonical
  • filter membership does not change
  • list semantics are simple

Invalidate/refetch when:

  • filtering/ranking is server-owned
  • pagination/cursors are affected
  • permissions are involved
  • local reconciliation could diverge from backend behavior

Memory vs persistence​

Use memory by default.

Add IndexedDB only if product requirements justify:

  • offline mode
  • restart persistence
  • expensive-to-refetch data

Application cache vs HTTP cache​

Use both.

Application cache -> correctness + UX
HTTP cache -> transport efficiency

35. 60-Second Interview Answer​

I would build a browser data client around deterministic query keys, an in-memory query cache, and an in-flight request map. Fresh results return immediately; stale results can use stale-while-revalidate; identical concurrent reads share one Promise. For large entity-heavy applications I would normalize entities so filtered lists reference entity IDs instead of duplicating objects. Mutations either patch the canonical entity or invalidate affected query lists, especially when filtering, ranking, pagination, or permissions are server-owned. I would guard against stale GETs overwriting newer mutations using generations or server versions. Memory is bounded using LRU, max entries, max bytes, and garbage collection. The browser HTTP cache remains a separate lower layer using Cache-Control and ETag. IndexedDB is optional for persistence/offline behavior; localStorage is reserved for small preferences. Finally, I would instrument hit rate, stale reads, memory usage, evictions, deduplicated requests, and backend QPS reduction.


36. Whiteboard Checklist​

When drawing the design, include:

[ ] React Components
[ ] useQuery / useMutation
[ ] DataClient
[ ] Query Cache
[ ] Entity Store
[ ] Inflight Map
[ ] Subscription Manager
[ ] TTL / SWR
[ ] LRU / GC
[ ] Mutation Coordinator
[ ] IndexedDB optional layer
[ ] BroadcastChannel optional layer
[ ] Browser HTTP cache
[ ] Cache-Control / ETag
[ ] Server / DB

During deep dive, prioritize:

1. cache-key correctness
2. mutation/list invalidation
3. filtered-list membership
4. stale-response race
5. request dedupe
6. bounded memory
7. ETag vs application cache
8. tenant isolation

37. Common Interview Traps​

Trap 1: "TTL prevents memory problems"​

Incorrect.

TTL controls freshness, not maximum memory.

Trap 2: "Just use localStorage"​

Poor choice for large query caches because it is synchronous and string-based.

Trap 3: "Updating the entity automatically fixes every list"​

Only partly true.

A changed entity may need to leave or enter filtered lists.

Trap 4: "ETag replaces the client query cache"​

Incorrect.

ETag optimizes HTTP revalidation. It does not provide React subscriptions, optimistic updates, deduplication, or dependency invalidation.

Trap 5: "Just update all cached lists locally"​

Dangerous when backend filtering/ranking logic is complex.

Trap 6: "React Context can hold all server state"​

Possible for tiny apps, but broad Context updates can create unnecessary rerenders and poor cache encapsulation.


38. Read / Fetch Policies​

Section 9's read path always follows the same rule — fresh cache, else network — but real UIs need finer control per query. That control is a FetchPolicy:

type FetchPolicy =
| 'cache-only'
| 'network-only'
| 'cache-first'
| 'cache-and-network'
| 'stale-while-revalidate'
| 'no-cache';
PolicyBehaviorGood for
cache-onlyReturn cached data only; never hit the network; error/empty on a missOffline mode, preloaded/static data
network-onlyAlways hit the network and overwrite the cacheHighly dynamic or security-sensitive reads
cache-firstReturn fresh cache immediately; hit the network only on a miss or once staleMostly static data
cache-and-networkReturn cache immediately (if present) and kick off a background refreshDashboards, product surfaces where perceived speed matters
stale-while-revalidateSame behavior as cache-and-network, treated as the default rather than an opt-inStrong default for most product reads
no-cacheHit the network but never persist the resultOne-off or sensitive reads (e.g. a token exchange)

Wiring policy into the read path​

The policy decides when the network runs and whether its result is trusted over the cache. Everything else from section 9 — the in-flight map for deduplication, the generation check from section 18 that guards against stale responses — stays identical regardless of policy:

async function query<T>({
key,
fetcher,
staleTime = 60_000,
policy = 'cache-first',
}: {
key: unknown[];
fetcher: () => Promise<T>;
staleTime?: number;
policy?: FetchPolicy;
}): Promise<T> {
const cacheKey = hashKey(key);
const cached = cache.get(cacheKey) as QueryEntry<T> | undefined;
const now = Date.now();
const isFresh = cached?.data !== undefined && now < cached.staleAt;

if (policy === 'cache-only') {
if (cached?.data === undefined) {
throw new Error('Cache miss for cache-only policy');
}
return cached.data;
}

if (policy === 'cache-first' && isFresh) {
return cached!.data as T;
}

if (
(policy === 'cache-and-network' || policy === 'stale-while-revalidate') &&
cached?.data !== undefined
) {
// Serve the cache immediately; refresh happens in the background using
// the same in-flight/generation machinery as any other request.
void query({ key, fetcher, staleTime, policy: 'network-only' });
return cached.data;
}

// network-only, no-cache, and cache misses all fall through to a real
// request. `no-cache` is identical except the result is never persisted.
const request = fetcher();

if (policy !== 'no-cache') {
request.then((data) => {
cache.set(cacheKey, {
data,
updatedAt: Date.now(),
staleAt: Date.now() + staleTime,
generation: cached?.generation ?? 0,
subscribers: cached?.subscribers ?? new Set(),
});
notify(cacheKey);
});
}

return request;
}

Typical default​

stale-while-revalidate (== cache-and-network)

is the strongest default for product UI: users see cached data instantly, and it self-heals in the background without a loading spinner.

Policy is also how "offline" gets modeled​

navigator.onLine is only a hint (see section 27), but it is a convenient signal to force reads down to cache-only and hold mutations in a queue rather than sending them network-only:

const policy: FetchPolicy = navigator.onLine ? 'stale-while-revalidate' : 'cache-only';

39. Query Key Factories​

Section 7 covers what must go into a key. In practice, keys stay correct — and hierarchical invalidation stays easy — behind a small factory instead of hand-written arrays scattered across the app:

type UserFilters = {
teamId?: number;
status?: 'active' | 'inactive';
};

const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
};

This buys hierarchical invalidation for free:

// Invalidate every user query (every list and every detail).
client.invalidate(userKeys.all);

// Invalidate only one user's detail query.
client.invalidate(userKeys.detail('2'));

The same pattern covers pagination. For page-based pagination, the page number lives in the key so pages coexist independently:

userKeys.list({ teamId: 42, page: 1 });
userKeys.list({ teamId: 42, page: 2 });

For cursor pagination, an infinite query instead accumulates pages under a single key:

type InfiniteQueryEntry<T> = {
pages: T[];
pageParams: unknown[];
nextCursor?: string;
};

40. Cache Model Comparison: Normalized Entities vs Query-Result Cache​

Section 17 introduced normalized entity caching. Two real-world libraries sit at either end of the spectrum, and picking one matters for the "query result cache vs normalized entity cache" tradeoff in section 34.

Query-result caching (TanStack Query style)​

['users', { status: 'active' }] -> [User1, User2]
['user', '1'] -> User1

The same entity (User1) may be duplicated across several cached query results. Consistency relies on explicitly invalidating or patching every affected key after a mutation, exactly as sections 14–16 describe.

Normalized entity caching (Apollo/Relay style)​

Entity Store
User:1 -> { id: 1, name: 'Alice' }
User:2 -> { id: 2, name: 'Bob' }

Query Store
activeUsers -> [User:1, User:2]

Updating User:1 in the entity store automatically updates every query that references it — at the cost of more complex cache semantics and a dependency on a well-connected schema, which is why this model fits GraphQL particularly well.

Rule of thumb​

REST / mixed APIs, loosely connected entities -> query-result cache (TanStack Query style)
GraphQL, strongly connected entity graph -> normalized cache (Apollo style)

Either model still needs everything covered above: correct cache keys, a deliberate fetch policy per read, generation/version checks against stale responses, and bounded memory.


41. Reference Implementation: Wiring This Up with TanStack Query​

Everything above is deliberately framed as a from-scratch DataClient so the underlying mechanics stay explicit. In production, most teams reach for a library like TanStack Query, which already implements the read path, deduplication, generation tracking, and GC — configured through the same policies described in section 38:

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // fresh window — see section 11
gcTime: 5 * 60_000, // retention window — see section 11
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: (failureCount, error) => {
if (error instanceof HttpError && error.status >= 400 && error.status < 500) {
return false; // don't retry client errors — see section 27
}
return failureCount < 3;
},
},
},
});

Reads map directly onto the key-factory pattern from section 39 and default to stale-while-revalidate behavior:

function useUsers(filters: UserFilters) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: () => api.getUsers(filters),
staleTime: 30_000,
});
}

Mutations pick the same three strategies as section 14 — here, patch the canonical detail and invalidate the lists whose membership may have changed:

function useUpdateUser() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Partial<User> }) => api.updateUser(id, patch),
onSuccess: (updatedUser) => {
queryClient.setQueryData(userKeys.detail(updatedUser.id), updatedUser); // canonical response
queryClient.invalidateQueries({ queryKey: userKeys.lists() }); // membership may have changed
},
});
}

The policy table in section 38, the key factory in section 39, and the cache-model choice in section 40 are exactly the decisions a library like this asks you to make — it just hides the deduplication and generation-tracking plumbing behind configuration.


Final Staff Engineer Principle​

Treat client caching as a consistency system, not just a performance optimization. First define identity, ownership, freshness, mutation semantics, and race handling. Then optimize latency and bandwidth with request deduplication, stale-while-revalidate, normalization, bounded memory, and HTTP revalidation.