Part III β The Speed Β· Lesson 6.9
API Caching
In one lineCache responses by key in the client, then decide the one hard thing: when they stop being true.
Where you've seen itPressing back to a product list and seeing it render instantly β populated, scrolled, no spinner.
Diagram
CACHE KEY LIFECYCLE
useQuery(['orders', {status:'open', page:2}])
βββββββββββ the KEY: endpoint + every parameter ββββββ
β
βββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ
βΌ βΌ βΌ
βββββββββββ fresh βββββββββββ stale βββββββββββ
β FRESH β ββββββββββββββββΊβ STALE β ββββββββββββββΊβ GARBAGE β
β serve, β staleTime β serve β gcTime β evicted β
β no fetchβ elapsed β + refetchβ + unused β β
βββββββββββ βββββββββββ βββββββββββ
staleTime = how long the data is trusted (0 by default)
gcTime = how long unused data is kept in memoryTHE FOUR INVALIDATION TRIGGERS
1. TIME staleTime expires simplest
2. MUTATION user edits β invalidate related keys most important
3. EVENT WebSocket says entity changed (2.4) most accurate
4. FOCUS tab regains focus / reconnects cheap and effective
Mutation-based invalidation is the one candidates forget.OPTIMISTIC UPDATE β and the rollback that must exist
user clicks "Like"
β
βββΊ write cache immediately UI updates in 0ms
β
βββΊ POST /like βββΊ β 200 βββΊ reconcile with server response
β βββΊ β 500 βββΊ ROLL BACK to the snapshot
β + surface the failure
ββ always snapshot before writing, or rollback is impossibleHow it works
- Key by everything that changes the response β endpoint, filters, page, sort, and user/locale where relevant.
- Set
staleTimeper resource. A currency rate is stale in seconds; a country list is fresh for a day. The default of0refetches constantly. - Invalidate on mutation: after a successful write, mark the affected keys stale so dependent screens refetch.
- Deduplicate in flight. Three components requesting the same key must produce one network request.
- Snapshot before optimistic writes, and always implement rollback β the failure path is the whole feature.
const { mutate } = useMutation({
mutationFn: likePost,
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: ['post', id] });
const prev = qc.getQueryData(['post', id]); // snapshot
qc.setQueryData(['post', id], (p) => ({ ...p, liked: true }));
return { prev };
},
onError: (_e, id, ctx) => qc.setQueryData(['post', id], ctx.prev), // rollback
onSettled: (_d, _e, id) => qc.invalidateQueries({ queryKey: ['post', id] }),
});Trade-offs
| Approach | Freshness | Complexity |
|---|---|---|
| No cache | Perfect | Slow, spinner on every navigation |
| TTL only | Predictably stale | Low |
| Mutation invalidation | Correct after own writes | Medium |
| Event-driven (WS) | Correct after anyone's writes | High |
| Optimistic + rollback | Instant, occasionally wrong | Highest |
Interview angle
"Two components need the same data. How many requests?"
- One β the cache deduplicates by key and both components subscribe to the same entry.
- That's precisely why the key must include every parameter; a missing filter serves the wrong cached data.
- Layer invalidation: TTL as the floor, mutation invalidation for your own writes, and events for other users' writes.
Recap
- The key is the contract: endpoint plus every parameter that changes the answer.
staleTimeper resource, mutation invalidation for correctness, events for multi-user accuracy.- Optimistic updates without a snapshot and rollback are a bug, not a feature.