API · Graphql

GraphQL Caching Patterns

GraphQL Caching Patterns is the work that makes the systems talk. The API is the contract between the producer and the consumer; the contract is what determines whether the.

John Kihiu12 min read

REST caching mostly falls out for free: GET requests are cacheable by definition, the URL is the cache key, and any CDN or browser cache understands that out of the box. GraphQL breaks that model almost entirely — most clients POST every operation to a single /graphql endpoint, so there's no distinct URL per resource and no HTTP-cacheable GET to hang a CDN rule on. Getting caching back requires deliberately rebuilding, at several different layers, what REST got for nothing.

Why naive HTTP caching doesn't work

A CDN or browser cache keys on method + URL (plus a few headers). When every query and every variable set POSTs to the same /graphql path, the cache has nothing to key on — two completely different queries look identical to the cache layer. Even switching to GET doesn't help much on its own, because the query and variables end up in a long, high-cardinality query string that's still effectively unique per request.

Persisted queries restore GET caching

Persisted queries fix the cardinality problem: instead of sending the full query text, the client sends a hash that identifies a query already registered with the server (either uploaded ahead of deploy, or cached server-side the first time it's seen — "automatic persisted queries" in Apollo's terms). With a stable, short identifier, the request can now genuinely be a GET — /graphql?extensions={"persistedQuery":{"sha256Hash":"..."}}&variables=... — and that URL is cacheable by a CDN the same way any REST GET is.

Field-level response caching

Apollo Server supports cache hints declared right in the schema via the @cacheControl directive, specifying a maxAge in seconds and a scope of PUBLIC or PRIVATE per field. The server computes an overall cache policy for the response as the most restrictive combination of every field touched by the query — one PRIVATE field anywhere in the selection set makes the whole response private.

GRAPHQL · SCHEMA
type Product @cacheControl(maxAge: 300) {
  id: ID!
  name: String!
  price: Float
  viewerSpecificDiscount: Float @cacheControl(scope: PRIVATE, maxAge: 0)
}

That resolved policy can then drive an HTTP Cache-Control header on the response, or feed a response cache plugin (Apollo's own response-cache plugin, backed by Redis or an in-memory store) that caches the serialized result keyed by query + variables.

Normalized caching on the client

Apollo Client and Relay don't cache whole responses — they normalize every object out of the response tree into a flat store keyed by __typename plus the object's id (Apollo Client calls this the InMemoryCache; the cache key defaults to __typename:id). The payoff is that if two different queries both return the same Product:42, the client stores it once and both queries read the same normalized entry — updating it in one place updates every view referencing it.

__typename is not optional

Normalized caching depends on being able to identify an object uniquely across queries. If a type has no id field, or the client isn't requesting __typename on every selection (most client libraries add it automatically), the cache falls back to a non-normalized, query-scoped cache — updates stop propagating between views showing the same entity.

Invalidating the cache after a mutation

Because the client cache is normalized, a mutation that returns the updated object often invalidates itself for free — if the mutation response includes the same id and changed fields, the normalized store just overwrites that entry and every query observing it re-renders. For anything the mutation response doesn't cover, you fall back to explicit strategies: refetchQueries to re-run specific active queries, manual cache eviction (cache.evict() in Apollo Client) for objects that were deleted, or optimistic updates that write an assumed result into the cache immediately and reconcile once the real response arrives.

DataLoader is not a caching layer for this

It's worth being clear that DataLoader — the batching utility used to solve N+1 fetches inside resolvers — is not part of this caching picture. Its internal cache is scoped to a single request and exists purely to dedupe repeated .load(sameKey) calls while that request is being resolved; it's thrown away afterward and shares nothing across requests or users. Response caching, persisted queries, and the normalized client store are the layers that actually persist and get reused across requests.

Wrapping up

GraphQL caching is a stack, not a switch: persisted queries buy back CDN-level GET caching, @cacheControl hints drive server-side response caching with correct public/private scoping, and normalized client caches keep the UI in sync as mutations land. Skip any one layer and you'll still get correct results — just with more redundant round trips than the shape of the problem requires.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.