A single-page application shows stale data after a user makes a change elsewhere in the product. How should client-side caching and invalidation be designed?
Show the full answer Hide the answer
Why this happens
The client caches server responses to avoid refetching. The cache has no way to know that a mutation elsewhere in the application invalidated an entry, so a user who updates a record on one screen returns to a list showing the old value — and concludes the change did not save.
The perception cost exceeds the technical cost. Users respond to apparent data loss by repeating the action, which produces duplicates.
The design
- A normalised cache keyed by entity identity, so an entity appearing in a list and a detail view is one cached object. Updating it once updates every view — which eliminates the largest class of this problem structurally.
- Explicit invalidation on mutation: a mutation declares which queries it affects, and those are refetched or marked stale. This is the mechanism, and the declaration must be maintained — a mutation that forgets to declare an affected query is the recurring bug.
- Stale-while-revalidate as the default read behaviour: show the cached value immediately, refetch in the background, update when it arrives. The user sees something instantly and correctness follows within a moment.
- Optimistic updates for the acting user, with a rollback path and honest notification on failure — routinely under-built, because rejections are rare in testing and confusing in production.
- Refetch on window focus and on reconnection, which catches changes made in another tab or on another device cheaply.
- A defined staleness policy per data type, since a user's own profile, a shared document and a reference list have completely different tolerances — applying one policy everywhere is either wasteful or wrong.
For genuinely collaborative data
Where multiple users change the same data concurrently, invalidation is insufficient because the client does not know a change occurred.
- Server-pushed invalidation or updates over a websocket or server-sent events, so the client learns rather than polls.
- Polling with a sensible interval where a push channel is not justified.
- Versioned entities, so a stale write can be rejected rather than silently overwriting.
- And an explicit decision about conflicts: last-writer-wins per field handles most cases well, while long-form text needs the conflict surfaced to the user, because silent resolution destroys work.
The framing that matters
A client cache is a replica, and every replica has a consistency model — whether or not anybody chose one. Most staleness bugs are the result of a consistency model that was never stated, so the useful discipline is naming it per data type: this is read-your-writes, this is eventually consistent within a minute, this is refreshed on focus.
And where staleness is unavoidable, saying so in the interface converts a perceived bug into communicated behaviour — which is the cheapest fix available and the one most often skipped.