Caching Strategies

Why GraphQL breaks HTTP caching, normalized client caches, persisted queries, and field-level response caching.

Why GraphQL breaks the usual HTTP caching model

REST caching leans entirely on one fact: a GET request to a stable URL returns a stable shape of data, so a browser, CDN, or reverse proxy can cache a response keyed by that URL with no extra machinery at all. GraphQL breaks that assumption at the root — nearly every request, including reads, is sent as a POST to the same single /graphql endpoint, with the actual query and variables carried in the request body. There's no URL for a generic HTTP cache to key on, and the same field can appear in a different shape (different sibling fields, different aliases) in every request, so there isn't a fixed "resource" being cached the way a REST response is.

Option 1: normalized client-side caching

GraphQL client libraries (Apollo Client, Relay, urql) solve a different problem than HTTP caching: instead of caching whole query responses, they cache individual objects, identified by type and ID, in a normalized store:

JSON
{
  "Post:123": { "id": "123", "title": "Understanding GraphQL", "author": { "__ref": "Author:7" } },
  "Author:7": { "id": "7", "name": "Ali Raza" }
}

When any query returns Post:123, the client updates that one normalized entry — and every other query currently on screen that also references Post:123 (even through a completely different query shape) reflects the update automatically, without a network round-trip. This is a fundamentally different caching model from REST's URL-keyed caching: it deduplicates and keeps objects fresh across different queries within one client session, but it does nothing for a fresh session, a different user, or load on the server itself.

Option 2: persisted queries

A persisted query is registered with the server ahead of time and identified by a short hash instead of being sent as full query text on every request:

JSON
{
  "extensions": { "persistedQuery": { "sha256Hash": "abc123..." } },
  "variables": { "postId": "123" }
}

This buys two real wins. First, request payloads shrink dramatically — a hash instead of what can be kilobytes of query text, which matters especially on mobile connections. Second, and more importantly for caching: because the hash makes a persisted query's identity short and stable, it can be sent as a plain GET with the hash and variables in the query string instead of a POST body:

Http
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}&variables={"postId":"123"}

This is what actually restores ordinary URL-based HTTP and CDN caching for GraphQL — something a typical POST-with-a-body call can never get, no matter how it's configured. Automatic Persisted Queries (an Apollo convention) make adoption incremental: the client first tries sending just the hash; if the server doesn't recognize it yet, it replies with a "not found" error, the client sends the full query once to register it, and every later call for that same query can use the short hash form from then on.

Option 3: server-side field-level response caching

Rather than caching a whole response, cache the (often expensive) result of an individual resolver, with its own freshness window per field:

Graphql
type Post {
  id: ID!
  title: String! @cacheControl(maxAge: 300)
  comments: [Comment!]! @cacheControl(maxAge: 10)
}

A post's title rarely changes and can be cached for minutes; its comment count changes constantly and should barely be cached at all — expressing that distinction within a single response is something REST's "cache this whole response for N seconds" model has no clean way to do. A server or CDN that understands these directives can apply the more conservative of the per-field values to the overall response, or cache at the resolver/object level directly.

Comparison table

Approach What it caches Works with Restores CDN/browser caching?
Normalized client cache Individual objects, by type + ID, client-side Any GraphQL client library (Apollo, Relay, urql) No
Persisted queries (+ GET) A whole query response, keyed by query-hash + variables Server and client cooperating on registration Yes, for the GET form
Field-level @cacheControl Individual field/resolver results, server-side A server implementation with directive support Partially — informs server/CDN response caching, per field

Common mistakes

  • Expecting a CDN to cache GraphQL responses the way it caches REST GETs without adopting persisted queries (or another URL-stabilizing mechanism) first — a POST body isn't something a generic HTTP cache keys on at all.
  • Treating client-side normalized caching as a substitute for actual server-side or CDN caching — it avoids redundant requests within one client's session, but does nothing for load from other clients or a fresh session.
  • Applying one blanket cache duration to an entire GraphQL response when different fields in it have very different freshness needs.
  • Forgetting that persisted queries require the client and server to agree on a registered set of queries ahead of time — an ad hoc, never-before-seen query can't be looked up by hash, and either needs the automatic registration fallback or is rejected outright on a locked-down, allow-list-only API.