firsthand

ADR-0014: Invalidation by tags, not by cache keys

Status: superseded by ADR-0022 (2026-09-21)

Tags survived; the cache around them did not. Identity derived from tags turned out to make two unrelated call sites share one entry, which ADR-0022 records with the measurement. What follows is the original decision, kept because the argument for tags over keys is still the argument this project makes.

Problem

Applications need a cache in front of the network: deduplicated requests, a shared result, a way to say “this is wrong now, fetch it again”. React Query is the shape people know, and the brief asks for a replacement that works for REST and GraphQL, where invalidation is expressed in tags rather than in one cache key, tags may carry variables, a query may have several, and a mutation may invalidate several queries at once.

The problem with the key-as-handle design is not performance, it is coupling. A key is a query’s identity, so it is written by whoever wrote the query; making it also the invalidation handle means every mutation has to know the key shapes other people chose, and stay in step with them. The information the mutation actually has is different: it knows what it changed, not who is watching.

Constraints

Options considered

  1. React Query’s model, ported. Key-as-handle plus prefix matching. Familiar and rejected by the brief, and prefix matching makes the order of a key’s segments a public contract — ['user', id, 'posts'] can be invalidated by user but not by posts.
  2. Normalised entity cache (Apollo, urql’s graphcache). Responses are flattened into entities by id, and a mutation returning a user patches every query mentioning that user, with no invalidation at all. Genuinely better when it works: it needs ids everywhere, schema knowledge, and merge policies for every list, and it does not apply to REST at all. Far more than 3 kB.
  3. Tags, separate from identity. Chosen. Identity defaults to the tags, so the usual query writes one thing; key separates the rare two queries that share tags.

Chosen design

A tag is a name plus variables. tag('user', { id: 7 }). Matching is: same name, and every variable the pattern names has the same value on the candidate. Variables the pattern leaves out are wildcards, so tag('user') covers every user and tag('user', { id: 7 }) covers exactly one. That single rule replaces prefix matching and does not depend on the order anything was written in.

An entry holds signals, not a snapshot. data, error, status and fetching are cells on the cache entry. Every component that asks for the same tags reads the same cells, so a refetch updates all of them with no subscriber list, no copies, and no comparison — this is the whole of “a refetched value is displayed automatically”.

Definitions are thunks. useQuery(() => ({ tags: [...], fetch })). A component body runs once, so a plain object would freeze the variables at setup. Re-evaluating the thunk moves the subscription to another entry when a variable changes, which is an ordinary reactive update rather than a re-render.

Invalidation is two-speed. A matching entry is marked invalid; if someone is watching it, it re-fetches immediately, otherwise on next use. A cache that re-fetched everything would turn one mutation into a thundering herd.

GraphQL directives. @tag(name: "user", id: $id) on the operation or on a field, bound against the call’s variables. Real directives rather than comments, because a comment is not part of the language and every tool in the chain — formatters, linters, editors — treats it as noise; a directive is syntax those tools already understand.

They are removed from the document before it is sent. A server that has not declared @tag in its schema rejects the whole query, and declaring a directive in the schema for something only the client cares about is a strange thing to ask of an API — Apollo strips @connection for the same reason. The stripping is a hand-written scanner that knows where strings and comments are (about eighty lines) rather than a GraphQL parser, which is several times the size of this whole package. It lives in a module that imports nothing but tags, so the build-time .graphql loader can use it without pulling the runtime in, and an application using the loader ships no parser at all.

A malformed directive throws, which under the loader is a build failure.

Performance implications

Memory implications

DX implications

Rejected alternatives