Concepts — A Server-State Query Cache

Phase 5 · Spec area §35. Prerequisites: fw-08.

1. Why this matters

Server state is not client state, and treating it as such is one of the most expensive architectural mistakes in frontend work. Server state is shared, stale by default, and asynchronously invalidated by actors you cannot see. A query cache is the machinery that admits this.

This is also the densest concentration of race conditions in the track. Build the races deliberately.

2. Architecture

query key -> cache entry { data, status, updatedAt, subscribers }
          -> fetch (deduplicated, cancellable)
          -> notify subscribers

3. Build order

  1. Key normalisation (structural equality, stable ordering).
  2. Cache with status: fresh | stale | fetching | error.
  3. Deduplication: N simultaneous subscribers, one request.
  4. Stale-while-revalidate.
  5. Retries with backoff; distinguish retriable from terminal errors.
  6. Cancellation on unsubscribe.
  7. Invalidation, exact and by key prefix.
  8. Background refetch (focus, reconnect, interval).
  9. Optimistic updates with rollback.
  10. Garbage collection of unobserved entries.
  11. Pagination and dependent queries.

4. Failure Lab — build every race

  1. Out-of-order responses. Request A then B for the same key; A resolves last. Show A's data winning. Fix with a sequence number; explain why timestamps are insufficient.
  2. Optimistic rollback onto a changed base. Apply an optimistic update, then a different server update arrives, then the optimistic one fails. Roll back to what?
  3. Dedup + cancellation. Three subscribers share one request; one unsubscribes. Does the request abort? Should it?
  4. Refetch storm. Window focus triggers refetch of 50 queries simultaneously.
  5. GC race. An entry is collected while a component is mid-mount.
  6. Cross-tab. Two tabs, same key, one mutates.

4.5 Deep dive: why timestamps fail for last-write-wins

The obvious fix for out-of-order responses is "keep the newest." It does not work.

t=0   request A dispatched
t=10  request B dispatched
t=50  response B arrives   (server processed at t=20)
t=90  response A arrives   (server processed at t=15)

Which is newer? By response arrival, A. By server processing, B. By request dispatch, B. Only the third is under your control and monotonic on the client.

Client clocks are also not trustworthy across tabs, and server timestamps have clock skew and insufficient resolution — two writes in the same millisecond are indistinguishable.

The rule: order by a monotonic counter you control, incremented at dispatch. Not by wall clock, not by arrival, not by server time. A sequence number is one integer and it is exactly correct.

Same conclusion as fw-08's navigation ordering, and for the same reason — which is why these two modules are adjacent.


4.6 Deep dive: optimistic rollback when the base moved

The genuinely hard case, and the one most implementations get wrong.

state:  { title: "A" }
user edits    → optimistic { title: "B" }
server push   → { title: "C", author: "X" }   (someone else changed it)
your mutation FAILS

Roll back to what? "A" discards the other user's change. Keep "C" and the optimistic edit silently vanishes — which is correct but confusing. Merge and you are writing a CRDT.

The practical answers, in increasing order of honesty:

  1. Snapshot-and-restore — restore the pre-mutation value. Simple; loses concurrent updates.
  2. Invalidate and refetch — discard local state, ask the server. Correct, costs a round trip, and flickers.
  3. Store the optimistic change as a separate layer applied over server state, removed on failure. Correct and composable; substantially more machinery.

Most libraries do (1) and document it. Option (3) is what you need when concurrent editing is real.

Design rule worth stating in a review: optimistic UI is a latency optimisation that trades correctness under concurrency. It is right for low-contention data (your own profile) and wrong for high-contention data (a shared counter, a seat booking). "Is this data contended?" is the question, and it is a product question, not a technical one.


4.7 Deep dive: the cache-key problem

useQuery(['todos', { status: 'done', page: 1 }])
useQuery(['todos', { page: 1, status: 'done' }])   // same query, different object

Keys must be structurally compared with stable ordering, or you get duplicate entries, doubled requests, and invalidations that miss.

The sharp edges:

  • Key order must not matter — serialise deterministically.
  • Undefined vs missing should usually be the same key.
  • Functions and class instances in keys are un-serialisable and usually a design error.
  • Partial matching for invalidation (['todos'] invalidates ['todos', ...]) requires the key to be a path, which is why array keys beat string keys.

Note the resemblance to bi-08's constraint-space cache key and fw-07's content hash: the key must capture every input that affects the value, and no more. Too little and you serve stale data; too much and you never hit the cache.


4.8 Deep dive: normalised vs document caches

Document cacheNormalised cache
Storeswhole responses per keyentities by id, queries as id lists
Update one entitymust invalidate every query containing itupdate once, all queries see it
Requiresnothinga schema, or id extraction
Complexitylowhigh
Chosen byReact Query, SWRApollo, Relay, Redux Toolkit Query (partly)

The trade is: normalised caches give automatic consistency across queries and demand that you describe your data model to the cache. Document caches stay simple and push consistency onto explicit invalidation.

Most libraries chose document caches, and the reason is worth understanding: REST responses have no reliable identity. Without ids and a schema you cannot normalise. GraphQL clients normalise because GraphQL gives them __typename and id for free.

That is a case where the data format determined the client architecture — a good example for any argument about API design having downstream consequences far beyond the wire.

5. Trade-offs

Cache-first vs network-first. Latency vs freshness; the right answer differs per query, which is why these libraries are configuration-heavy.

Normalised vs document cache. Normalisation gives consistency across queries and costs a schema and complexity. Most libraries chose document caches; say why.

Optimistic updates. Better perceived latency, and rollback is genuinely hard when the base moved underneath.

6. Principal Engineer Review

  1. Why is a timestamp insufficient for last-write-wins? Give the failing scenario.
  2. Optimistic update rollback when the base changed: specify the correct behaviour, and defend it.
  3. Argue that server state should never live in a client state manager. Then give the exception.
  4. Design invalidation for a mutation affecting an unknown set of queries. What do you give up?
  5. A team reports "the cache shows stale data sometimes." Give your diagnostic procedure.