One Endpoint, Infinite Keys
One Endpoint, Infinite Keys
HTTP caching was designed around resources identified by URLs. GraphQL gives you one URL, a POST body that changes per request, and a response assembled from fields with wildly different freshness: a published article is immutable for a year, its comment count changes by the minute, and the "recommended for you" block must never be stored by anything shared.
Leaving it all uncacheable wastes the best cache you have. Field-level cache hints solve it by turning the response's freshness into a function of its parts:
type Article {
id: ID!
body: String! @cacheControl(maxAge: 600, scope: PUBLIC)
publishedAt: Date! @cacheControl(maxAge: 600, scope: PUBLIC)
commentCount: Int! @cacheControl(maxAge: 60)
}
type Query {
article(slug: String!): Article
recommendations: [Article!]! @cacheControl(maxAge: 30, scope: PRIVATE)
}
The rules the server applies when it builds the header:
| Situation | Resulting header |
|---|---|
| every selected field has a max-age | public, max-age=min(all selected) |
| any selected field is PRIVATE | private instead of public |
| any selected field has no hint and no default | no-store — one unknown field poisons the whole response |
So { article { body commentCount } } answers with Cache-Control: public, max-age=60 — the minimum of 600 and 60, because a response is only as fresh as its stalest-needed field. Add recommendations to the same document and the header drops to private, max-age=30, which shared caches must not store at all.
The lesson inside the lesson: caching decisions belong at the field where the freshness knowledge lives, and they propagate upward. A client cannot know that commentCount expires in a minute, and a CDN cannot see inside the response body — the header is the only channel that carries field-level truth to the layers above.
Choosing a Layer for Each Read Pattern
Choosing a Layer for Each Read Pattern
There are three caches in a federated stack and they answer different questions. Reaching for the wrong one is how teams end up with stale personalized data or a CDN that never hits.
| Layer | Keyed by | Answers | Wrong when |
|---|---|---|---|
| CDN / browser | method + URL (query id + variables) | "has this identical public request been served before?" | response is identity-dependent |
| Gateway entity cache | subgraph + __typename + key fields + field set |
"did some other client just read this object?" | entity mutates faster than the TTL |
| Per-request loader | field + argument values, scoped to one request | "does this request ask for the same row twice?" | treated as a cross-request cache |
The per-request loader is the one people misuse: a DataLoader lives for exactly one request and exists to collapse duplicates and N+1 patterns, not to remember anything. It expires at the end of the response, by design.
Entity caching at the gateway pays off when many clients converge on the same objects — a home page where every visitor requests the same fifty products. The key must include the field set, because "product with price" and "product with price and stock" are different entries; a key that omits the selection serves truncated data. Keep the TTL short (seconds, not minutes) and treat it as a latency optimisation rather than a source of truth, because the subgraph underneath will keep writing.
The CDN layer only works once requests are GET-addressable, which is the whole point of the hash-based approach in persisted queries and CDN: the query id plus canonicalized variables gives the CDN a stable URL per document-plus-arguments pair, and the cache-control rules from the previous chapter decide whether that URL is storable at all.
Invalidation, Personalization, and Poisoning
Invalidation, Personalization, and Poisoning
Nothing caches correctly until you can say what happens when the data changes. Three mechanisms, in increasing order of machinery:
- Short TTL. Works when staleness of seconds is acceptable — comment counts, feeds, dashboards. Costs nothing, guarantees eventual freshness, and is the right default until something complains.
- Tag-based purge. Tag each cached entry with the entities it contains (
Cache-Tag: article-123,Surrogate-Key), then purgearticle-123on mutation. One tag invalidates every query, page, and variant that touched the article, which is the only scalable answer when thousands of distinct documents embed the same row. - Entity write-through. The subgraph that mutates an entity evicts or rewrites the gateway's entity-cache entry for that type and id. Precise, but only works inside a federated topology where the gateway can be told what changed.
Personalization is a header problem, not a key problem. The tempting fix is Vary: Authorization, which turns every token into its own cache copy — your hit ratio collapses to roughly zero while you still pay for the storage. Instead: mark the personalized fields scope: PRIVATE, let the response go out as private, no-store, and keep shared layers serving only documents composed entirely of public fields.
Never cache a failure. A 200 carrying entries in errors[], a partially nulled response after a subgraph timeout, or a cost rejection replayed for ten minutes turns a transient incident into a sustained one. The rule: cache only responses with data and no errors, and make sure Set-Cookie is absent from anything cacheable — a cookie on a shared response both fragments the cache and leaks sessions between users.
Finally, remember what sits outside all of this: subscriptions deliver over SSE or WebSocket and have no URL to cache against, so realtime paths need delivery semantics of their own rather than borrowed HTTP headers.
Practice Problems
Given field-level cache hints, derive the exact Cache-Control header for each operation and decide whether a shared CDN may store it. Explain the rule that produced each header.
Example:
Input: hints: article.body @cacheControl(maxAge: 600, scope: PUBLIC) article.commentCount @cacheControl(maxAge: 60) recommendations @cacheControl(maxAge: 30, scope: PRIVATE) op A: { article(slug: "gql") { body commentCount } } op B: { article(slug: "gql") { body } recommendations { title } }
Output: op A → Cache-Control: public, max-age=60 (min(600, 60); all public) → CDN stores it op B → Cache-Control: private, max-age=30 (min(600, 30); PRIVATE wins) → shared CDN must not store
A shared response is only as fresh as its stalest field, so 60 not 600; for B the private recommendations force the private directive even though the article itself is public, so a shared cache storing it would leak one user’s recommendations to the next. Both are correct outputs of the same two rules: minimum wins, private wins.
Optimal Solution — O(F) for F selected fields time, O(1) beyond the response itself space
Walk the executed response tree, collect each field’s effective hint (directive value or server default, else unknown), fold to min max-age and the strictest scope, then emit the header and the store decision.
function buildCacheControl(selectedFields) {
let maxAge = Infinity;
let scope = 'public';
let storeable = true;
for (const f of selectedFields) {
if (f.maxAge === undefined) { storeable = false; break; } // no hint, no default
maxAge = Math.min(maxAge, f.maxAge);
if (f.scope === 'PRIVATE') scope = 'private';
}
if (!storeable) return 'no-store';
if (maxAge === 0) return 'no-store';
return scope + ', max-age=' + maxAge;
}
// op A: min(600,60) public → 'public, max-age=60'
// op B: min(600,30) private → 'private, max-age=30' Edge Cases:
- A field with a directive but no maxAge contributes nothing and leaves the rest cacheable
- Adding one unhinted field to a document silently downgrades the whole response to no-store
- Aliases of the same field are separate response entries — each carries its own hint
Quiz
1. A response contains fields with maxAge 600, 60, and one private field. What Cache-Control goes out?
2. Why is a DataLoader not a response cache?
3. A page embeds one article referenced by 4,000 distinct query documents. How do you invalidate all of them on edit?
4. Why is Vary: Authorization usually the wrong answer for personalized GraphQL responses?
Flashcards
Question
Response max-age rule
Click to reveal answer
Answer
Minimum maxAge across every selected field; any private field forces private; an unhinted field forces no-store.
Question
Entity cache key
Click to reveal answer
Answer
subgraph + __typename + key fields + requested field set — omit the field set and you serve truncated data.
Question
Three cache layers
Click to reveal answer
Answer
CDN (URL, cross-client public), gateway entity cache (type+id, cross-client reads), DataLoader (per-request dedupe only).
Question
Invalidation ladder
Click to reveal answer
Answer
short TTL → tag/surrogate-key purge on mutation → entity write-through eviction at the gateway.
Question
Never cache
Click to reveal answer
Answer
responses with errors[] entries, cost rejections, or Set-Cookie — and never store private data behind Vary: Authorization.
Revision Notes
Key Takeaways
- 1. Freshness is a property of fields and propagates to the response as the strictest combination
- 2. Match the layer to the read pattern: URL cache for public requests, entity cache for shared objects, loader for one request
- 3. Purge by entity tag on mutation and keep failures and personalized data out of shared caches
Interview Tips
- • Derive a header on the spot from a set of field hints — interviewers are checking min + private + no-store semantics
- • Name the layer and the key before naming the TTL; the key is where most GraphQL cache bugs actually live
Cheat Sheet
Header derivation
max-age = min(maxAge of every selected field)
any PRIVATE field → "private"
any unhinted field (no default) → "no-store"
Layers
| Layer | Key | Use |
|---|---|---|
| CDN | query id + sorted variables | public GET responses |
| Entity cache | subgraph+__typename+keys+field set | repeated object reads |
| Loader | field+args, one request | batch/dedupe only |
Invalidation: TTL for seconds-level staleness → tag purge (Cache-Tag/Surrogate-Key) per entity → write-through on mutation.
Never store: errors[] responses, private fields, anything with Set-Cookie. Subscriptions bypass HTTP caches entirely.