Combining Automatic Persisted Queries with CDN Caching
AI generated
{ }
type
GraphQL · Caching · Web Performance
Combining Automatic Persisted Queries with CDN Caching
Making GraphQL cacheable as a GET request

GraphQL requests default to POST with the query text in the body, and CDNs never cache POST responses. Automatic Persisted Queries solve this by replacing the query with a stable SHA256 hash and sending the request as GET. That suddenly makes CDNs, browser caches and edge layers usable, without the frontend losing the actual query logic.

18 min read Automatic Persisted Queries · Apollo Client · CDN · Edge Caching GraphQL · Fastly · Cloudflare · Varnish

1. Why POST-based GraphQL never reaches a CDN

A classic GraphQL request sends the entire query text as a JSON body via POST to a single endpoint, usually /graphql. That is unfavorable for HTTP caching in two ways: browsers, CDNs and reverse proxies do not cache POST responses by default, and even if you forced that, there is no stable cache key, because the query text can differ between builds, formatting and whitespace variants. Two functionally identical queries with different indentation produce different cache entries even though they would return the same response.

For REST APIs, edge caching is a given: a GET request to /api/products/42 has a fixed URL that a CDN can use as a cache key. GraphQL breaks that model because the same URL can potentially answer hundreds of different queries. This is exactly where Automatic Persisted Queries come in: they give every query a deterministic, short identity that can be treated like a REST URL. Without this pattern, GraphQL remains structurally disadvantaged compared to REST on large public catalog pages or content APIs.

The pain grows with traffic volume: an online shop serving product data via GraphQL to thousands of concurrent visitors hits the origin server on every single request, because no intermediate cache can absorb it. Automatic Persisted Queries shift that load to where it belongs: to the edge, close to the user, milliseconds instead of hundreds of milliseconds away.

2. The APQ protocol: a two-step hash handshake

Automatic Persisted Queries follow a simple protocol standardized by Apollo. The client computes a SHA256 hash over the exact query string and, on the first attempt, sends only that hash, without the full query text. If the server does not yet know the hash, it responds with the error code PersistedQueryNotFound. The client registers that response and sends a second request containing both the hash and the full query, at which point the server stores both in the persisted query store, usually Redis or another distributed key-value store.

Every subsequent request, including from other clients, only needs to transmit the hash. That not only reduces payload size significantly, it also creates exactly the stable identity caching needs: the same hash always means the same query, regardless of formatting or client version. Automatic Persisted Queries differ from statically generated persisted query manifests built ahead of time in that the handshake happens at runtime and needs no extra build pipeline.


# Original query, hashed with SHA256 for Automatic Persisted Queries
query GetProductBySku($sku: String!) {
  product(sku: $sku) {
    id
    name
    price {
      regularPrice { amount { value currency } }
    }
    media {
      url
      altText
    }
  }
}
# sha256Hash: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85

// Step 1 — client sends only the hash, no query text
POST /graphql
{
  "operationName": "GetProductBySku",
  "variables": { "sku": "MS-1042" },
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85"
    }
  }
}

// Server response — hash unknown, ask for the full query
{
  "errors": [{ "message": "PersistedQueryNotFound" }]
}

// Step 2 — client retries, this time with hash AND query
POST /graphql
{
  "operationName": "GetProductBySku",
  "variables": { "sku": "MS-1042" },
  "query": "query GetProductBySku($sku: String!) { product(sku: $sku) { id name } }",
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85"
    }
  }
}

3. Client implementation with Apollo Client

Apollo Client ships the reference implementation for Automatic Persisted Queries directly as a link. createPersistedQueryLink handles hashing, the two-step handshake and the automatic retry on PersistedQueryNotFound, without application code ever noticing. The critical piece is combining it with createHttpLink, which determines whether the second, successful request is actually sent as GET, because that GET request is precisely the prerequisite for CDN caching.

In practice it is enough to place the persisted query link before the HTTP link in the Apollo link chain. For pure read access, such as product queries in an online shop, it is also worth adding a size limit: very long GET URLs hit limits on some proxies, which is why Apollo Client automatically falls back to POST if the resulting URL exceeds a configurable length. That fallback matters so Automatic Persisted Queries do not break on edge cases like very complex, deeply nested queries.


// apollo-client.js — Automatic Persisted Queries with GET for CDN caching
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { createHttpLink } from "@apollo/client/link/http";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";

// GET only for the successful, hash-only request — this is what CDNs can cache
const httpLink = createHttpLink({
  uri: "https://shop.mironsoft.de/graphql",
  useGETForHashedQueries: true,
});

const persistedQueryLink = createPersistedQueryLink({ sha256 });

export const client = new ApolloClient({
  link: persistedQueryLink.concat(httpLink),
  cache: new InMemoryCache(),
});

4. Server-side persisted query cache

On the server side, Automatic Persisted Queries need a store that keeps hash-to-query mappings available across requests and server instances. Apollo Server supports this through a cache adapter, usually Redis, so that every instance behind a load balancer knows the same hash and doesn't need to run the PersistedQueryNotFound handshake separately per instance. Without a shared cache, every instance switch would retrigger the two-step handshake, wiping out the performance benefit.

A sensible TTL for the persisted query store is well above the cache time of the actual responses, often several weeks, because query shapes change less often than the underlying data. It's important to separate two caches: the persisted query store holds the hash-to-query-text mapping, while a separate response cache, or the CDN itself, holds the actual JSON responses. Only both together deliver the full benefit of Automatic Persisted Queries.


// server.js — Apollo Server with a shared Redis-backed persisted query cache
import { ApolloServer } from "@apollo/server";
import { KeyvAdapter } from "@apollo/utils.keyvadapter";
import Keyv from "keyv";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  // Shared across all instances behind the load balancer
  persistedQueries: {
    cache: new KeyvAdapter(new Keyv("redis://redis:6379")),
    ttl: 60 * 60 * 24 * 30, // 30 days — query shapes rarely change
  },
});

5. From hash to GET request: the CDN prerequisite

The actual caching benefit only materializes once the second, successful request is sent as GET. The hash and variables travel as query parameters in the URL, for example /graphql?extensions={"persistedQuery":{"sha256Hash":"…"}}&variables={"sku":"MS-1042"}. That URL is deterministic: same hash, same variables, same URL, same cache entry. That is precisely what distinguishes Automatic Persisted Queries from plain POST GraphQL, where every request looks unique from an HTTP caching perspective.

It's important that only read access actually runs over GET. Mutations must stay POST, since GET requests are supposed to be idempotent and free of side effects under HTTP semantics. Apollo Client separates this automatically: only queries use useGETForHashedQueries, mutations are unaffected by it. Anyone building their own clients, for example for mobile apps without Apollo, must replicate that separation manually, or risk accidentally caching write operations.

6. CDN configuration: cache keys, vary, TTL

At the CDN, whether Fastly, Cloudflare or a classic Varnish layer, the configuration needs three things: a cache key that includes the hash and variables, correct vary headers for locale- or store-dependent responses, and a deliberately chosen TTL. The origin server has to set matching cache-control headers for that, for example Cache-Control: public, max-age=300, s-maxage=3600, where s-maxage specifically controls the edge cache time and is independent of the browser cache time.

In multilingual or multi-store setups, as they commonly occur in Magento, the cache key must also account for the store context, otherwise the CDN serves German prices to a US store. A proven pattern is to explicitly include the store header in the vary header and treat it as part of the cache key, instead of relying solely on cookies, which many CDNs exclude from the cache key by default.


# Inspect cache behavior for an Automatic Persisted Queries GET request
curl -I "https://shop.mironsoft.de/graphql?extensions=%7B%22persistedQuery%22%3A%7B%22sha256Hash%22%3A%22e3b0c4%22%7D%7D&variables=%7B%22sku%22%3A%22MS-1042%22%7D" \
  -H "Store: de_DE"

# Expected headers on a cache HIT
# HTTP/2 200
# cache-control: public, max-age=300, s-maxage=3600
# vary: Store, Accept-Language
# x-cache: HIT
# age: 118

# Origin (Apollo Server / Express) sets the header per response
# res.set("Cache-Control", "public, max-age=300, s-maxage=3600");

7. Invalidation on deploys and mutations

Cache invalidation matters for Automatic Persisted Queries in two cases: if the query itself changes, for example through a new frontend release, a new hash and thus a new cache entry appear automatically, and old entries simply expire via TTL. More critical is the case where the underlying data changes while the hash stays the same, for example when a product price updates. Pure TTL-based caching only helps to a degree here, because stale data is served between the price change and the TTL expiry.

The robust solution is surrogate-key-based invalidation, as offered by Fastly and Varnish: the origin server sends an additional header, for example Surrogate-Key: product-1042, and when a product is saved, exactly that surrogate key is purged at the CDN via a targeted API call, without flushing the entire cache. For setups without surrogate keys, only a conservatively short TTL remains, which significantly reduces the caching benefit of Automatic Persisted Queries for volatile data.

8. Security: persisted-query-only mode and denylisting

Automatic Persisted Queries bring a security benefit that often gets overlooked: in persisted-query-only mode, the server accepts only hashes already stored, and categorically rejects arbitrary, free-form query text. That rules out an entire class of attacks where malicious clients deliberately construct expensive, deeply nested queries to overload the server, since only queries known in advance, registered during the build process, can be executed at all.

For public APIs meant to be accessed by arbitrary third-party clients, this mode is unsuitable, because it assumes all allowed queries are known ahead of time. For internal APIs, such as your own storefront frontend, it is the recommended configuration. Additionally, you can set up denylisting for individual abused hashes, for example when a compromised client sends requests at an unusually high frequency, without disabling the entire persisted query feature.

9. Automatic Persisted Queries compared

Whether Automatic Persisted Queries pay off depends heavily on the traffic profile. For small internal tools with few users, the added effort is barely noticeable. For public, high-traffic storefronts, the combination of Automatic Persisted Queries and CDN caching is often the difference between an origin server that buckles under load and one that never even sees most requests.

Approach HTTP method CDN-cacheable Assessment
Standard GraphQL (POST) POST No Every request hits origin, no edge relief
APQ without GET switch POST No Smaller payload, but still not cacheable
Automatic Persisted Queries + GET GET Yes Stable cache key, CDN-ready, mutations stay POST
Persisted-query-only + CDN GET Yes Also protects against arbitrary, expensive queries

The table shows: the decisive step is not hashing alone, but the switch to GET. Without GET, Automatic Persisted Queries remain a pure payload optimization with no CDN benefit. Only the combination of hash handshake, GET requests and matching cache-control configuration at the origin makes GraphQL traffic genuinely edge-cacheable, with all the performance and security benefits that follow from it.

Mironsoft

GraphQL architecture, caching and performance for Magento & headless frontends

Is your GraphQL API buckling under load at origin?

We set up Automatic Persisted Queries, configure CDN caching with clean cache keys and invalidation, and make sure your GraphQL storefront stays fast even under peak load.

APQ setup

Connecting Apollo Client and Server with persistedQueryLink and a Redis cache

CDN configuration

Cache keys, vary headers and surrogate-key invalidation for Fastly, Cloudflare, Varnish

Security review

Hardening with persisted-query-only mode against expensive, abused queries

10. Summary

Automatic Persisted Queries solve a structural problem of GraphQL: POST requests with variable query text are unsuitable for HTTP caching. The two-step hash handshake gives every query a stable identity that can be transmitted as a GET request. Only that switch to GET opens the door to CDN caching, with cache keys built from hash and variables, correct vary headers for store and locale context, and surrogate-key-based invalidation for volatile data.

Anyone running Automatic Persisted Queries in production should keep three things cleanly separated: the persisted query store for hash-to-query mappings, the response cache at the CDN for the actual answers, and persisted-query-only mode as a security layer against arbitrary, expensive queries. Together these building blocks produce a GraphQL API that gets answered at the edge under load, not at the origin.

Automatic Persisted Queries with CDN Caching — Key Takeaways

Hash handshake

SHA256 hash instead of query text, two-step handshake on PersistedQueryNotFound, after that the hash alone suffices.

GET instead of POST

Only GET requests are CDN-cacheable. Apollo Client controls this via useGETForHashedQueries, mutations stay POST.

Cache keys & invalidation

Hash plus variables plus store context in the cache key. Surrogate keys enable targeted purging instead of a full cache flush.

Security

Persisted-query-only mode blocks arbitrary query text and protects against expensive, abused requests.

11. FAQ: Automatic Persisted Queries with CDN Caching

1What are Automatic Persisted Queries?
A protocol that sends only a SHA256 hash instead of the full query text. If the hash is unknown server side, PersistedQueryNotFound requests the full query, after that the hash alone suffices.
2Why isn't POST GraphQL cacheable?
Browsers and CDNs don't cache POST responses by default, and without a stable hash there's no deterministic cache key for identical queries.
3Is APQ alone enough for a CDN?
No. Only useGETForHashedQueries in Apollo Client switches to GET, which is the actual prerequisite for CDN caching.
4Can mutations run as GET?
No. GET must stay free of side effects. Apollo Client applies the GET switch only to queries, mutations are always POST.
5How to invalidate on data changes?
Surrogate-key-based purging removes exactly the affected cache entries without flushing the whole cache. Pure TTL caching otherwise serves stale data briefly.
6What is persisted-query-only mode?
A server mode that accepts only known hashes and rejects free-form query text. Protects against expensive ad-hoc queries, but only suits controlled, internal clients.
7Which cache adapter for Apollo Server?
A shared store like Redis via KeyvAdapter, so every instance behind the load balancer uses the same hash store.
8How to handle multi-store setups?
Set store or locale context explicitly as a vary header and include it in the cache key, otherwise responses from different stores get mixed up.
9What happens with very complex queries?
The GET URL stays short since only hash and variables are transmitted. Apollo Client automatically falls back to POST once a configurable length is exceeded.
10Worth it for small internal tools?
Usually not. The benefit scales with traffic and public reach, for few users the configuration effort outweighs it.