GraphQL Caching
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
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 storePOSTresponses regardless of what headers the response carries. -
Even where a query is sent as a
GET, the URL is dominated by a large, client-chosenquerystring (plus avariablespayload), 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 |
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=60lets a CDN or browser serve the response for up to 60 seconds without contacting the origin again;stale-while-revalidatelets it serve a slightly stale copy while it re-validates in the background rather than blocking the client on a fresh fetch. -
ETaggives the cache a content fingerprint it can send back asIf-None-Matchon the next request, letting the origin answer304 Not Modified(no body) when nothing has changed instead of re-sending the full payload. -
Varytells the cache which request headers split one URL into multiple cache entries — for example, if the response shape depends onAccept-Languageor an authorization-derived context, that header belongs inVarytoo, 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 |
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 ( |
|
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 |
Normalized-store merge keyed on the object’s global |
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 |
Per-field hints |
Server-framework-specific annotations that compute the response’s overall |
Client normalized cache |
Stable, globally unique |
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.