caching

why

a cache promises a fresh answer cheaply; a second key, owner, or invalidation path silently reverses that promise — entries no event can reach are unbounded stale data wearing a cache's name. one canonical identity and one invalidation path keep the cache a derived durable fact, provably consistent with its source, not a second drifting source of truth.

flowchart LR
F[underlying durable facts] -->|compute| P[projection]
P -->|one key from shared factory| K[(cache entry)]
S[source change event] -->|invalidate narrowest family| K
U[reader] --> K
K -.stale past contract.-> M[marked stale · never silently served]

rule

a cache entry is a derived durable fact: one canonical identity, one owning boundary, an explicit freshness contract, an invalidation path that reaches it. cannot name all four → not a cache, just unbounded stale data.

failure modes:

  • no invalidation path → serves stale answers indefinitely (profile update never reaches a route cache; old name served for an hour or until restart).
  • wrong cache family invalidated → some surfaces unrefreshed, others nuked and recomputing.
  • two identities for one projection → two caches in two services drift apart as mutations touch only one; "which cache am i looking at" at 3 a.m.

identity and ownership

concernowner
cache identitythe same domain query-key factory the client uses — query key ownership.
server-side entrythe domain service or projection that computes the answer.
freshness signalthe source that changes the underlying facts — event, hook, or schedule.
client cacheTanStack Query, per TanStack Query.

server and client share the key factory vocabulary. a projection under two identities is two caches drifting toward different answers.

freshness contract

declare freshness at design time, in the slice or contract:

patternwhen
event invalidationthe source publishes a trusted change event; the entry invalidates on receipt.
write-through invalidationthe owning mutation invalidates or updates the smallest affected key family.
ttl fallbacka background-refresh floor for entries whose source emits no events.
no cachethe fact is per-request (authorization decisions, rate-limit state) or intentionally always fresh.

consumer status projection records the canonical pattern: durable facts first, events as freshness signals, one projection with one read path.

do not

  • create a cache identity outside the key factory.
  • invalidate the whole cache after a local mutation — narrowest affected family only.
  • cache authorization verdicts, rate-limit counters, or other per-request truth.
  • serve a stale entry past its declared freshness without the interface marking it stale, per truthful product states.
  • store a cache with no named invalidation path because "it'll be fine".

verification

  • list every cache identity; each must name owner, freshness contract, invalidation path.
  • mutate an underlying fact: affected entries invalidate (or refresh), unaffected families do not.
  • load the surface twice; the second read comes from the entry, not a fresh computation — prove it by log or counter at the computing boundary.

good and bad

bad

// service A
const key = `orders:${userId}`;
// service B, same projection
const cacheKey = `user-${userId}-orders`;
// both invalidated by clearAll() after any mutation

two identities for one projection, invalidated wholesale: caches drift toward different answers; one mutation nukes unrelated families; users see different data depending on which service answered; stale entries survive indefinitely because no event reaches their key; every miss after a wholesale clear is a full recompute storm; "which cache am i looking at" becomes the price of every inconsistency report.

good

const key = orderKeys.forUser(userId); // shared key factory
// owning mutation invalidates the narrowest family:
queryClient.invalidateQueries({ queryKey: orderKeys.forUser(userId) });

one identity from the shared factory, one owner, smallest affected family invalidated — unaffected entries stay warm. the cache stays provably consistent with its source; invalidation cost stays proportional to the mutation, not the whole cache. server and client readers share one key vocabulary, removing the "works on one surface, stale on another" defect class.

search pages

go to any page