GraphQL Caching

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly.

A single POST /graphql endpoint defeats most of the caching infrastructure the web already relies on, so GraphQL caching is largely about deliberately re-creating what HTTP, CDNs, and client-side normalized stores would otherwise give an API for free.

Why GraphQL complicates HTTP caching

HTTP caches — browsers, CDNs, reverse proxies — key their entries primarily on method and URL, with Vary narrowing that further by request header. A REST API built around many URL-addressed resources (GET /books/42) fits that model directly: the URL is the cache key, and a CDN can store and serve a response without understanding anything about the resource itself. A GraphQL API built the conventional way breaks this in two ways at once:

  • Every request is typically a POST, and HTTP caches never store POST responses regardless of what headers the response carries.

  • Even where a query is sent as a GET, the URL is dominated by a large, client-chosen query string (plus a variables payload), so two functionally identical requests rarely produce identical URLs, and a cache keyed on the full URL fragments into one entry per client-authored query text rather than one entry per underlying resource.

None of this is a flaw in the specification — request caching simply is not part of what GraphQL standardizes. The graphql.org/learn/caching page frames caching explicitly as something a server and its infrastructure choose to layer on top, not something the wire format guarantees, and every technique below exists to recover one piece of what a resource-oriented API gets from HTTP semantics alone.

Making GraphQL requests cacheable: parameterized GET

The most direct fix is to send side-effect-free queries (never mutations) as GET requests with the query document and variables encoded as query-string parameters, so the request becomes a plain URL a CDN or browser cache can store under ordinary HTTP rules:

GET /graphql?query=query+GetBook($id:ID!){book(id:$id){title}}&variables={"id":"42"} HTTP/1.1
Host: api.example.com
Accept: application/json

This is a real trade-off, not a free win: the query text itself is often long enough to blow past comfortable URL-length limits, and unless the query text is canonicalized byte-for-byte, two clients that mean the same query but format it with different whitespace still produce different cache keys. In practice, GET caching is almost always paired with persisted queries (below) so the URL carries a short identifier instead of the full query text.

This is generated documentation, produced with the assistance of AI — verify the exact GET request shape your server framework expects against its own docs before relying on it, since the query-string encoding of query/variables/operationName is a widely followed convention rather than something the core specification mandates.

Persisted queries as cache keys

A persisted query replaces the query text in a request with a short, stable identifier — typically a hash of the query document — that both the client and server already agree refers to a specific, previously-registered operation. This directly solves the URL-length and canonicalization problems above, because the identifier is the cache key, and it is short and deterministic by construction:

GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"a1b2c3..."}}&variables={"id":"42"} HTTP/1.1
Host: api.example.com

With automatic persisted queries (APQ), the client first sends only the hash; if the server has not seen that hash before, it responds with a PersistedQueryNotFound error, and the client retries once with the full query text alongside the hash so the server can register it for next time. After that first round trip, every subsequent call for the same operation — from that client or any other — can go over GET, be cached by a CDN keyed on the hash, and never re-transmit the query text at all. Persisting queries also has a security benefit that is out of scope for this page: Security & demand control covers using an allowlist of persisted operations to reject arbitrary client-supplied query documents entirely.

See graphql.org/learn/caching for the identifier format this section follows; a server framework’s own docs (linked from the relevant integration page in this section) cover how to configure automatic persisted queries for that specific stack.

HTTP caching headers

Once a request is cacheable at all — a parameterized GET, ideally over a persisted-query identifier — the response can carry the same headers any other HTTP resource would, and a CDN or browser applies its usual rules without any GraphQL-specific logic:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60, stale-while-revalidate=30
ETag: "f0a1b2c3d4"
Vary: Accept-Encoding
  • Cache-Control: public, max-age=60 lets a CDN or browser serve the response for up to 60 seconds without contacting the origin again; stale-while-revalidate lets it serve a slightly stale copy while it re-validates in the background rather than blocking the client on a fresh fetch.

  • ETag gives the cache a content fingerprint it can send back as If-None-Match on the next request, letting the origin answer 304 Not Modified (no body) when nothing has changed instead of re-sending the full payload.

  • Vary tells the cache which request headers split one URL into multiple cache entries — for example, if the response shape depends on Accept-Language or an authorization-derived context, that header belongs in Vary too, or two different users' responses can be served to each other.

These headers behave exactly as HTTP caching defines them everywhere else on the web — GraphQL adds no headers of its own. The catch is that a single GraphQL response is usually an aggregate of many independent pieces of data (a book and its author and its reviews, each with a different natural freshness window), so a single max-age for the whole response is necessarily a compromise: it has to be the shortest max-age any field in the response would tolerate, or some field goes stale for longer than its own data actually allows.

Per-field cache hints

Some server frameworks expose cache hints directly in the schema or resolver so an individual field can declare its own freshness window and cache scope, and the server computes the response’s overall Cache-Control header as the minimum of every hint that contributed to that response — the most conservative field wins:

type Query {
  book(id: ID!): Book
}

type Book {
  title: String        # rarely changes: a long max-age is safe
  reviews: [Review!]!  # changes often: a short max-age, or PRIVATE scope
}

A field marked PUBLIC can be cached by a shared CDN on behalf of every client; a field marked PRIVATE (for example, anything derived from the requesting user’s own identity or entitlements) must only be cached by that one client’s own browser cache, never by a shared intermediary. This mechanism is not part of the core specification — it is a convention some server implementations add on top — so the exact directive or annotation syntax is specific to whichever server framework is in play; see that framework’s own docs (linked from the relevant integration page in this section) for the concrete syntax it exposes.

Per-field cache hints and their PUBLIC/PRIVATE scoping are implementation-specific extensions, not something the specification defines — verify the exact hint syntax and default scope against your server framework’s own documentation before relying on it, since defaults differ across implementations.

Why the client normalized cache needs stable global IDs

Everything above is server-side or transport-level caching — it decides whether a response has to be recomputed at all. A GraphQL client also typically maintains its own normalized cache: rather than storing each query’s response as an opaque blob keyed by that query’s text, the client flattens every object it receives into a flat store keyed by a stable, globally unique identifier, and re-assembles each query’s result from that store on read. The payoff is that fetching the same Book through two different queries — one that asked for its title, another that asked for its reviews — merges into one cached object instead of two disconnected copies, and updating that object once (after a mutation, or a fresh fetch) is instantly visible to every query that referenced it, without a full refetch.

This only works if every object the client caches carries an id that is stable across requests and globally unique across types — a Book with id: "42" and a Review with id: "42" must never collide in the same flat store. That is exactly the guarantee the Relay Global Object Identification specification’s Node interface and opaque id encoding provide, and it is why a client-side normalized cache is, in practice, a strong argument for adopting that pattern schema-wide rather than an unrelated nicety: Global Object Identification covers the Node interface, the node(id:) root field, and the opaque id encoding itself in depth. Client-side caching (see Apollo Client configuration) covers how one specific client library configures its normalized store’s cache keys, including the escape hatches for types that lack a usable id.

query GetBookWithReviews($id: ID!) {
  node(id: $id) {
    id
    ... on Book {
      title
      reviews {
        id
        rating
      }
    }
  }
}

Because every object in that response carries its own globally unique id, a client cache can normalize the Book and each Review into independent entries, and a later query for the same Book by the same id — even one that selects different fields — reads from (and merges into) that same cached entry instead of starting over.

Cache invalidation across layers

The three layers above invalidate differently, and conflating them is a common source of stale data:

Layer Invalidated by Typical mechanism

CDN / HTTP cache

Time (max-age expiry) or an explicit purge

Cache-Control expiry, or a purge API call keyed on URL or a cache tag

Server-side field/response cache

Time, or an explicit invalidation on write

Cache hint TTL, or an application-level cache bust in the mutation resolver that changed the data

Client normalized cache

A mutation’s response updating the store directly, or a fresh query re-fetching the same id

Normalized-store merge keyed on the object’s global id, or an explicit refetchQueries/cache eviction call

A mutation is the most common trigger across all three: the client-side cache updates immediately from the mutation’s own response (assuming that response selects the changed fields, keyed by the same id the read query used), while any CDN or server-side cache entry for the read query that produced the now-stale data has to be purged or left to expire on its own schedule — there is no automatic link between a mutation succeeding and a separately cached GET response becoming aware of it.

Summary

Layer What makes it work

HTTP / CDN

Cacheable GET requests — ideally over a short, stable persisted-query identifier — plus ordinary Cache-Control/ETag/Vary headers

Per-field hints

Server-framework-specific annotations that compute the response’s overall Cache-Control as the minimum of every field’s own hint

Client normalized cache

Stable, globally unique id`s on every cacheable object, most reliably provided by the `Node interface from Global Object Identification

See graphql.org/learn/caching for the foundation’s own overview of these techniques, and Performance & N+1 for the resolver-level techniques (batching, projections) that reduce the cost of a request that a cache miss falls through to. See Tips: When to Use GraphQL or a Typical REST API for how this caching trade-off weighs against a typical REST API’s native cacheability.