Setting @cacheControl Hints in Federated Subgraphs

Every caching layer above your subgraphs — the CDN, the router’s response cache, a client’s normalised store — takes its instructions from hints the subgraphs emit. Get the hints wrong and either nothing caches or the wrong thing does, and both failures are quiet. This page covers where hints go, how the router combines them across a federated response, and how to find the single field that is disabling caching for a whole page. It sits under Caching Strategies for Federated GraphQL.

When to use this pattern

  • Use it on any read-heavy graph where the same data is requested repeatedly by many clients, which is most graphs.
  • Use it before adding a cache, because a cache in front of a graph that emits no hints will either cache nothing or cache things it should not.
  • Skip hints entirely for a write-heavy internal graph where every response is per-user and short-lived — the hints would all be no-store and the annotation is noise.

Prerequisites

The rule that decides everything: minimum wins

The router computes a response’s cacheability from every field the operation touched, and it takes the minimum. The shortest maxAge in the selection becomes the response’s maxAge, and any PRIVATE field makes the whole response private.

That single rule explains almost every caching surprise in a federated graph. A page that looks entirely cacheable produces no-store because one field — often a small one, often added last week, often in a subgraph the page’s owner does not think about — carries no hint at all. An unhinted field defaults to zero, and zero is the minimum of any set.

It also explains why hints are a graph-wide concern rather than a per-subgraph one. Your subgraph’s careful annotation is worth nothing on a response that also selects an unhinted field from somebody else’s, and no check will tell you that happened.

One unhinted field sets the whole response to zero Three fields carry generous max-age values and a fourth carries none. Because the router takes the minimum, the response is uncacheable and every layer above is bypassed. Four fields, one verdict Product.name maxAge 3600, PUBLIC Product.price maxAge 300, PUBLIC Product.stock maxAge 30, PUBLIC Product.badge no hint → 0 Cache-Control: no-store CDN and router cache both bypassed The badge was added last sprint by a team that never thought about caching, and it disabled it for a page owned by somebody else. Nothing errored and no check noticed.

Implementation walkthrough

Hints go on types and on fields, with the field-level hint winning where both apply. Annotating the type gives you a default that a new field inherits, which is the single most effective defence against the failure above.

# catalog subgraph
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
  @link(url: "https://specs.apollo.dev/cacheControl/v0.1", import: ["@cacheControl"])

# A type-level default: any new field inherits this unless it overrides it.
type Product @key(fields: "id") @cacheControl(maxAge: 300, scope: PUBLIC) {
  id: ID!
  name: String!                                        # inherits 300 PUBLIC
  price: Money! @cacheControl(maxAge: 60)              # shorter, still public
  stock: Int!   @cacheControl(maxAge: 30)
  # Varies per viewer: PRIVATE partitions the cache rather than disabling it.
  recentlyViewedByYou: Boolean! @cacheControl(scope: PRIVATE, maxAge: 60)
  # Genuinely uncacheable, and marked so deliberately rather than by omission.
  liveAuctionBid: Money @cacheControl(maxAge: 0)
}

Dynamic hints are occasionally the honest answer, when cacheability depends on the data rather than on the field:

export const resolvers = {
  Product: {
    availability: (product: Product, _: unknown, __: Context, info: GraphQLResolveInfo) => {
      // A discontinued product's availability never changes; an active one's
      // changes constantly. One field, two very different lifetimes.
      const maxAge = product.discontinued ? 86400 : 30;
      info.cacheControl.setCacheHint({ maxAge, scope: "PUBLIC" });
      return product.availability;
    },
  },
};

The type-level default deserves emphasis because of what it does over time. Without it, every new field starts at zero and silently degrades every response that selects it. With it, a new field starts cacheable and a developer who needs otherwise has to say so — which is the direction you want the default pointing.

Verification steps

Read the outgoing header, because it is the only artifact that reflects every hint at once.

curl -si localhost:4000/graphql -H 'content-type: application/json' \
  -d '{"query":"{ product(id:\"P-1\"){ id name price { amount } } }"}' \
  | grep -i '^cache-control'
# expect: cache-control: max-age=60, public

Then find the field that is spoiling a page. Bisect the selection: remove half the fields, re-read the header, and keep the half that stays uncacheable. Three or four rounds isolates the culprit on any realistic page, and it is far faster than reading four subgraphs’ SDL.

# Same query minus one suspect field — if max-age jumps, you found it.
curl -si localhost:4000/graphql -H 'content-type: application/json' \
  -d '{"query":"{ product(id:\"P-1\"){ id name price { amount } } }"}' \
  | grep -i '^cache-control'

Finally, audit for unhinted fields mechanically rather than by review. A script that walks the composed supergraph and reports every field without an effective hint — its own or inherited from its type — turns “why is nothing caching” into a list you can work through.

What each hint tells the layers above Public with a max age is shared and reusable. Private partitions per audience. A zero max age disables caching. No hint at all is treated as zero, which is why omission is a decision. Four hints, four instructions hint means use for maxAge n, PUBLIC one entry serves everyone catalogue, content, reference data maxAge n, PRIVATE one entry per audience per-viewer values worth caching maxAge 0 never cache, deliberately live prices, balances, bids no hint at all treated as maxAge 0 nothing — always an accident

There is a governance point underneath all of this that is easy to miss while thinking about durations. Cacheability in a federated graph is a graph-wide property produced by decisions made independently in every subgraph, and nothing in the pipeline reconciles those decisions or reports when one of them changes the outcome for somebody else’s page.

That asymmetry argues for two lightweight conventions rather than a process. The first is that every entity type carries a hint, enforced by a linter, so the default for a new field is inherited rather than zero. The second is that a hit-rate metric is owned by someone — anyone — and watched, because a drop is the only signal that will ever tell you an unhinted field has appeared. Composition will not, checks will not, and the page will keep working while quietly costing several times more to serve.

Teams that adopt both find the whole subject becomes uneventful. Teams that adopt neither tend to rediscover the minimum rule every few months, usually during a traffic spike, usually while looking for a cause in the wrong service entirely.

Because the minimum rule makes cacheability a graph-wide outcome, the useful convention is a small set of named tiers rather than a number chosen per field. Four cover almost every case.

Name the tiers, not the seconds Reference, catalogue, volatile and live cover almost every field and make hint review a one-word decision. Four tiers instead of arbitrary numbers tier maxAge typical fields reference 3600 country lists, categories, static copy catalogue 300 names, descriptions, images volatile 30 stock levels, ratings, counts live 0 balances, bids, anything transactional Named tiers make a review question answerable: which tier is this field, rather than how many seconds. Four values also make the minimum rule predictable — a page is as fresh as its most volatile tier.

Common mistakes & gotchas

Marking a field PRIVATE when it is merely uncached. PRIVATE partitions the cache by audience, which is useful; using it as a synonym for “do not cache” multiplies storage for no benefit. Use maxAge: 0 when you mean uncacheable.

Hinting fields but not the type. Without a type-level default, every field added later starts at zero, and the page it appears on quietly stops caching. Set a default on the type and let fields narrow it.

Assuming the hint reaches the CDN. The router combines hints into a header, and something has to be configured to honour it. A perfectly hinted graph in front of a CDN that ignores Cache-Control caches nothing.

Frequently Asked Questions

Do hints affect the router’s entity cache too?

Yes — the entity cache uses the same hints to decide what to store and for how long, so a field with no hint is uncached at the entity layer as well as at the HTTP layer. That double effect is why an unhinted field is more expensive than it looks: it disables two layers, not one.

How do I choose a maxAge?

From how long a stale value is acceptable to a user, not from how often the data changes. A price that changes hourly may still be fine cached for a minute, and a catalogue name that changes yearly still wants a bounded age so a correction propagates. Start generous on stable data, short on volatile data, and adjust from hit-rate metrics rather than from intuition.

Can a mutation response be cached?

No, and the router will not try. What matters instead is invalidation: a mutation that changes an entity should evict the cached entity, or clients will read a stale value from a cache that has no idea anything happened. That is a separate mechanism from hints and needs its own wiring.

Should every subgraph agree on hint values?

On conventions rather than on numbers. Agreeing that every type carries a default, that PRIVATE means per-viewer, and that zero is written explicitly gets you most of the benefit. The actual durations belong to whoever owns the data, because only they know how stale is too stale.

Does an operation with an error still get cached?

No, and that interaction is worth knowing because it changes the shape of an incident. A response carrying an error entry is not cached, so a field that intermittently fails keeps missing the cache for the entire response — including all the fields that succeeded. A degraded subgraph therefore costs more than its own latency: it removes cache hits from operations that would otherwise never have touched it. That coupling is a good reason to let an unreliable optional field null out quickly rather than retrying it slowly.