skip to content

Cache Hints & Response TTL

Fields declare a max age and a public or private scope, and the response only lives as long as its shortest-lived field. Asked because one unhinted field silently makes the whole answer uncacheable.

part ofGraphQLoverview, primer and where to startread it →
on this pageshow

questions

3

How does a GraphQL server combine per-field cache hints into one response lifetime?

level: middleimportance: must knowfreq 55%

answer

  1. One body, one policy, chosen conservatively
  2. The slowest member sets the convoy speed
  3. Only fields the document selected count
  4. Minimum max age, most restrictive scope
  5. Unhinted composite field contributes zero

basics

~20 s

The server takes the minimum max age across every field the document selected, and the most restrictive scope: one private field makes the whole response private. A field with no hint contributes zero, which makes the response uncacheable.

solid answer

~50 s

After executing an operation the server folds the hints of the **selected** fields into a single policy. The lifetime is the **minimum** max age over those fields - the response can be no fresher than its most volatile part - and the scope is the most restrictive one seen, so a single private field makes the whole response private. Two defaults matter. A field returning an **object type** with no hint conventionally contributes **zero**, which drops the whole response to uncacheable; a field returning a scalar or enum with no hint **inherits** its parent object's hint, otherwise every leaf would zero out every response. Because the fold runs over the fields a document actually selected, one schema produces different lifetimes for different documents. None of this is in the specification, so defaults vary by server, but the minimum rule is universal.

code

graphql · 13 lines
graphql
# Schema
type Query { claim(id: ID!): Claim @cacheControl(maxAge: 60) }
type Claim @cacheControl(maxAge: 3600) {
  id: ID!
  policyType: PolicyType!
  openTaskCount: Int! @cacheControl(maxAge: 30)
}

# Document A -> lifetime 60
query StableClaim { claim(id: "c-1") { id policyType } }

# Document B -> lifetime 30
query ClaimWithTasks { claim(id: "c-1") { id policyType openTaskCount } }

go deeper

for a junior

Remember the headline: the response lives as long as its shortest-lived selected field, and one field with no hint can take the whole thing to zero.

for a middle

Explain the fold precisely - minimum max age, most restrictive scope, computed over the selected fields - plus the asymmetric defaults for unhinted composite versus leaf fields, and say clearly that this is convention, not specification.

for a senior

Demonstrate that you operate it: instrument the computed policy per operation, alert when a lifetime collapses after a deploy, and know that responses carrying errors should not be stored whatever the fold produced.

for a principal

Own the tradeoff of declaring freshness in a shared schema at all: who is allowed to set a number that changes every consumer's cache behaviour, and how you stop the minimum rule from making the cheapest annotation a de facto outage in cache hit rate.

## The rule One HTTP response body gets one freshness policy. GraphQL responses are assembled from many fields with many lifetimes, so the server has to collapse a set of hints into that single answer, and it does so conservatively: - **Lifetime = the minimum max age over the fields the document selected.** The response as a whole is only as fresh as its most volatile part. - **Scope = the most restrictive scope seen.** Any one field hinted private makes the entire response private. Both halves are conservative on purpose. Serving a stale field or leaking one viewer's value to another are both real defects; over-caching cannot be undone once a copy is out in a cache, while under-caching only costs origin traffic. ## A worked example Take a slice of an insurance claims graph: ```graphql type Query { claim(id: ID!): Claim @cacheControl(maxAge: 60) } type Claim @cacheControl(maxAge: 3600) { id: ID! policyType: PolicyType! openTaskCount: Int! @cacheControl(maxAge: 30) viewerCanReopen: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE) } ``` Three documents, three answers: - `{ claim(id: "c-1") { id policyType } }` - hints in play are 60 on the root field and 3600 inherited by the leaves. Minimum 60, scope public. - `{ claim(id: "c-1") { id openTaskCount } }` - now 30 joins the set. Minimum 30, scope public. - `{ claim(id: "c-1") { id viewerCanReopen } }` - 0 joins the set, and so does a private scope. Lifetime 0, scope private: nothing downstream stores it. Same schema, same server, three different policies. This is the single most important consequence of the rule, and the thing candidates most often miss: **cacheability is a property of the document, not of the schema.** ## The defaults, and why they are asymmetric What happens to a field with no hint at all is where implementations get interesting, and where the widespread convention is worth stating precisely. - A field whose return type is **composite** - an object, interface or union - and which carries no hint contributes **zero**. Composite fields are where new backend work appears, so the safe assumption is "unknown freshness". - A field whose return type is a **leaf** - a scalar or an enum - and which carries no hint **inherits** the hint of the object it belongs to. Without this, annotating a type would be pointless: every unannotated leaf would drag the minimum to zero and no schema would ever be cacheable without hundreds of annotations. So `type Claim @cacheControl(maxAge: 3600)` really does buy an hour for `id` and `policyType`, while adding a brand-new `type Claim { assignedAdjuster: Adjuster }` field and selecting it silently drops the response to zero until someone hints `Adjuster`. Servers differ in the details here - some let you configure the default for unhinted composite fields - so present this as the common convention rather than as a rule the specification lays down. ## Runtime narrowing A declared hint is a static property of a field, but freshness sometimes depends on the value. A claim in `SETTLED` is immutable in practice; the same claim in `IN_REVIEW` changes all afternoon. Servers commonly expose a way for a resolver to set a hint on the object it just produced, during execution: ```pseudocode resolve Query.claim(id): claim = claims.load(id) if claim.status == SETTLED: setCacheHint(maxAge = 3600, scope = PUBLIC) else: setCacheHint(maxAge = 20, scope = PUBLIC) return claim ``` The override changes what that field contributes; it does not change the fold. A resolver that sets an hour on one field cannot lift a thirty-second hint sitting on a sibling - the minimum still wins. ## Errors and partial data A GraphQL response can carry data and an `errors` entry at the same time, with holes where field errors landed. Storing such a response and replaying it to other callers replays a transient failure, so the conventional decision is not to cache a response carrying errors regardless of what the hints computed. Whether that decision is enforced by the server, the layer in front of it, or both varies; what matters in an interview is that you raise it, because the fold over hints alone would happily hand a failed response an hour. ## Why the granularity is the whole response A reasonable question is why per-field lifetimes are collapsed at all. The answer is what is being stored: one response body, in a cache that knows nothing about GraphQL. There is no mechanism by which such a cache keeps `policyType` for an hour and `openTaskCount` for thirty seconds out of the same body. Per-field lifetimes do exist - in a client's normalized store, and in server-side caches that memoize individual field values - but those are different machinery living at different layers, and neither is what a max-age hint is computing. ## Operating the rule Because one added field can silently zero a heavily-used operation, teams that rely on response caching instrument the fold itself: record the computed lifetime and scope per operation name, and alert when an operation that used to get minutes drops to zero. That signal fires on the deploy that introduced the field, rather than weeks later when someone notices origin traffic climbing.

  • Can a resolver widen a response's lifetime at execution time?
    It can raise the hint for its own field, but that cannot lift the response's policy above a shorter hint elsewhere in the selection, because the fold is still a minimum. Runtime hints are therefore useful for making one field's freshness depend on the value it returned - a settled claim versus one in review - not for rescuing a document that also selects something volatile.
  • Why is the fold a minimum rather than an average or the root field's hint?
    Because the failure modes are asymmetric. Under-caching costs origin traffic, which you can measure and pay for; over-caching serves data that is wrong, and once a copy is sitting in a cache downstream you cannot recall it. Taking the minimum guarantees no field is served past the freshness its owner declared, which is the only property that makes hints trustworthy to add.
  • Two operations select the same fields but pass different variables. Do they get the same computed lifetime?
    Yes - the fold looks only at which fields were selected, not at argument values, so the lifetime and scope are identical. Variables matter enormously for whether two responses are the same stored entry, but that is a cache-key concern handled by the layer doing the storing, not something the hint computation participates in.

The response travels as a convoy: it arrives no sooner than its slowest vehicle, and one classified vehicle makes the whole convoy classified.

saying these in an interview costs you the question

  • Says the response takes the longest field lifetime
  • Thinks each field is stored with its own lifetime
  • Believes the root field's hint decides the whole response
  • Assumes an unhinted field is treated as infinitely fresh
  • Says cacheability is fixed per schema, not per document
  • Would cache a response that carries an errors entry

context

open as a page

What does a per-field cache hint on a GraphQL schema declare, and what consumes it?

level: juniorimportance: should knowfreq 38%

basics

~20 s

A cache hint declares how many seconds a field's value stays fresh and whether it is public to every viewer or private to one. The server combines the hints of the selected fields into a single caching policy for the response.

open as a page

A GraphQL response stopped being shared-cacheable after one per-viewer field was added. Why, and what would you change?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Scope folds to the most restrictive value, so one field hinted private makes the entire response private and no cache shared between users may store it. The fix is to move the per-viewer field out of that document into its own operation.

open as a page