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.
Table of contents
- 1. Why POST-based GraphQL never reaches a CDN
- 2. The APQ protocol: a two-step hash handshake
- 3. Client implementation with Apollo Client
- 4. Server-side persisted query cache
- 5. From hash to GET request: the CDN prerequisite
- 6. CDN configuration: cache keys, vary, TTL
- 7. Invalidation on deploys and mutations
- 8. Security: persisted-query-only mode and denylisting
- 9. Automatic Persisted Queries compared
- 10. Summary
- 11. FAQ
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.