pattern

Normalised Client Cache

also called Entity Cache, Client-Side Store, Identity-Keyed Cache

Caching server responses as entities keyed by identity rather than as whole responses keyed by request, so that updating an entity once updates every view that displays it.

spacachinginvalidationstalenessoptimistic-updates

A response cache stores whole responses against the requests that produced them. The same entity therefore exists in several cache entries — once in the list response, once in the detail response, once inside a search result — and updating it updates only the entry that was refetched.

The user changes a record on a detail screen, returns to the list, and sees the old value. They conclude the change did not save, and repeat it, which produces duplicates.

A normalised cache stores entities by identity, with responses holding references. One update reaches every view.

Why it matters

The perception cost far exceeds the technical cost. Apparent data loss is one of the most damaging things an interface can do, because the user's rational response — retrying — creates a real problem out of a display problem.

Normalisation also removes an entire class of invalidation work. Without it, every mutation must enumerate and refetch every query that might contain the affected entity, and the mutation that forgets one is the recurring bug. With it, most updates propagate structurally and explicit invalidation is needed only where a mutation changes which entities belong in a collection.

Implementation patterns

  • A stable identity per entity type, present on every response — the precondition, and the thing that must be designed into the API. An endpoint that omits identifiers cannot be normalised.
  • Responses stored as references, so a list holds identities rather than copies.
  • Stale-while-revalidate as the default read: serve the cached value immediately, refetch in the background, update on arrival. The user sees something instantly and correctness follows.
  • Explicit invalidation only for collection membership changes — a created or deleted entity changes which list it belongs to, which normalisation alone cannot infer.
  • Optimistic updates written into the normalised store, so every view reflects them at once, with a rollback path and honest notification on rejection — routinely under-built because rejections are rare in testing and confusing in production.
  • Refetch on window focus and reconnection, catching other-tab and other-device changes cheaply.
  • A per-data-type staleness policy, since a profile, a shared document and a reference list have completely different tolerances.
  • Versioned entities, so a stale write can be rejected rather than silently overwriting.

Industry example

Normalised caching is the model behind the mature client data layers — Apollo's cache, RTK Query's entity adapters, Relay's store — all of which converged on it because the response-cache alternative produced exactly the staleness bugs described above, in every application that grew past a few screens.

It is also the foundation of the local-first sync engine approach taken further by products such as Linear: once the client holds a normalised store of entities, keeping it synchronised with deltas rather than refetching is a natural extension — and the per-feature development cost falls, because features are written against a local store rather than against an API.

Failure scenarios

  • Entities without stable identifiers, making normalisation impossible and forcing response caching.
  • Collection membership changes not invalidated, so a newly created item never appears in its list.
  • Optimistic updates with no rollback, leaving the client showing state the server rejected.
  • One global staleness policy, either refetching wastefully or serving stale data where it matters.
  • Unbounded cache growth in a long-lived session, with no eviction.
  • Nested entities inconsistently normalised, so some views update and others do not — worse than none updating, because the inconsistency is unpredictable.
  • Collaborative data treated as cacheable, where invalidation is insufficient because the client never learns a change occurred.
  • No versioning, so a stale optimistic write silently overwrites a newer server value.

Trade-offs

Normalisation adds real complexity to the data layer: identity management, reference resolution, garbage collection, and a mental model developers must hold. For a small application with few shared entities, a simple response cache with short TTLs is proportionate and much easier to reason about.

It also couples the client to the API's identity scheme, and it works poorly against endpoints that return computed aggregates or denormalised views with no stable entity identity — which is common in backend-for-frontend designs shaped for screens rather than for entities.

The trade is data-layer complexity and an API-design constraint in exchange for consistency across views and far less invalidation logic. For any application where the same entity appears on several screens — which is most of them past a certain size — the alternative is not simplicity but a recurring class of bug that is reported as "it did not save".

Interview question

"A user edits a record, goes back to the list, and sees the old value. Tell me why, what you would change in the client, and what you would need from the API for your fix to be possible."