Skip to content
advanced Phase 14 · Performance, Caching, and Production Hardening

Response and Field-level Caching

Applying cache hints, HTTP headers, and entity caches to GraphQL responses.

55m
1 problems
Topic Progress 0%

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:

  1. 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.
  2. Tag-based purge. Tag each cached entry with the entities it contains (Cache-Tag: article-123, Surrogate-Key), then purge article-123 on 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.
  3. 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

0 / 1 solved
Derive Cache-Control Headers for Two Operations
Cache Policy

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?

Question 1 options

2. Why is a DataLoader not a response cache?

Question 2 options

3. A page embeds one article referenced by 4,000 distinct query documents. How do you invalidate all of them on edit?

Question 3 options

4. Why is Vary: Authorization usually the wrong answer for personalized GraphQL responses?

Question 4 options

Flashcards

Question

Response max-age rule

Answer

Minimum maxAge across every selected field; any private field forces private; an unhinted field forces no-store.

Question

Entity cache key

Answer

subgraph + __typename + key fields + requested field set — omit the field set and you serve truncated data.

Question

Three cache layers

Answer

CDN (URL, cross-client public), gateway entity cache (type+id, cross-client reads), DataLoader (per-request dedupe only).

Question

Invalidation ladder

Answer

short TTL → tag/surrogate-key purge on mutation → entity write-through eviction at the gateway.

Question

Never cache

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.

Continue Learning