The Caching Problem hiding in the POST Body
The Caching Problem Hiding in the POST Body
CDNs and browser caches are URL machines: they key on method and URL, store the body, and replay it. GraphQL violates both halves of that contract — nearly everything is a POST, and the document that determines the response lives in the request body, invisible to the cache key. The result is one endpoint that is uncacheable no matter how much infrastructure you put in front of it.
The obvious fix — send GET requests with the full query in the URL — fails three ways: URLs blow past proxy limits at a few kilobytes, query text leaks into access logs and Referer headers, and every distinct document string becomes a distinct cache key, so anyone can generate unlimited URLs and churn your cache.
Automatic persisted queries (APQ) fix the key problem by splitting the document from its identity. The client sends only a SHA-256 hash; the server looks it up:
GET /graphql?variables={"id":"p1"}
&extensions={"persistedQuery":{"version":1,"sha256Hash":"9b4a..."}}
server: hash unknown
→ 200 { "errors": [{ "message": "PersistedQueryNotFound",
"extensions": { "code": "PersistedQueryNotFound" } }] }
client: retries with the full document attached
GET /graphql?query=query%20P(%24id%3A%20ID!)%7Bproduct(id%3A%24id)%7Bname%7D%7D
&variables={"id":"p1"}
&extensions={"persistedQuery":{"version":1,"sha256Hash":"9b4a..."}}
server: sha256(document) === 9b4a... → execute → 200 { "data": { ... } }
After the first exchange the hash is cached server-side, so steady-state traffic carries a few hundred bytes instead of the document, and the URL is short, stable, and identical for every execution of that operation. If the body arrives with a hash that does not match it, the server answers PersistedQueryMismatch and refuses to execute — a client bug or a tampered request, never a silent divergence.
From Automatic to Allowlisted
From Automatic to Allowlisted
Here is the trap: APQ lets any client register any document by simply sending hash and body together. It saves bandwidth and it does nothing about an attacker submitting a 40KB document with its own hash. For safelisting you need the closed version of the same protocol — the server executes only hashes it already knows.
client sends hash only, server has no entry
→ PersistedQueryNotFound
(strict mode: the server never asks for the body,
so the operation simply does not exist)
The manifest is a build artifact, generated from the queries your clients actually ship:
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync } from 'node:fs';
const manifest = {};
for (const file of readdirSync('./registered-queries')) {
const doc = readFileSync('./registered-queries/' + file, 'utf8');
const hash = createHash('sha256').update(doc).digest('hex');
manifest[hash] = { document: doc, operationName: file.replace(/\.graphql$/, '') };
}
// deploy manifest with the server; clients keep only the hash + variables
Two rules make this survivable:
- The hash must be computed over identical bytes on both sides. Normalize once (strip comments, collapse whitespace, pin field order) and normalize identically in CI and in the client build, or you will debug "hash never matches" for a day.
- Ship the manifest before the client. A client sending hash-only requests to a server without the entry gets
PersistedQueryNotFoundon every call — the failure mode is total, not partial, so the ordering is release-order significant.
The payoff is bigger than security: a hash identifies an immutable document, so the URL for ProductPage never changes. That is what makes the next chapter work.
Cache Keys That Actually Hit
Cache Keys That Actually Hit
With hash-only GET requests, a CDN can finally store GraphQL responses. Getting a high hit ratio requires fixing the parts of the key that still vary:
| Cache key component | Why it must be there |
|---|---|
| Query id (hash) | different documents are different resources |
| Canonicalized variables | {"id":"p1"} and {"id":"p2"} are different responses |
| Auth scope, or none | identity-dependent responses must never share a key |
Canonicalize variables before the URL reaches the CDN. JSON object key order is not stable across clients: {"a":1,"b":2} and {"b":2,"a":1} are one string apart in the URL and therefore two cache entries for one resource. Sort keys at the client (or at an edge worker) so the same logical request always produces the same URL.
Then decide what is shareable. A response containing me or recommendations is identity-dependent. The naive fix Vary: Authorization looks correct and destroys the cache, because the CDN now keeps one copy per token. The correct fix is in the response headers themselves:
- Public, identical for everyone:
Cache-Control: public, max-age=600— store it. - Identity-dependent:
Cache-Control: private, no-store— never store it, do not fragment the cache trying.
Only cache successful, data-bearing responses. A response with entries in errors[], a 200 with nulls after a subgraph timeout, or a cost rejection must not be replayed for its TTL — cache poisoning by error is the classic GraphQL CDN incident, and it is why Set-Cookie on a cacheable GraphQL response is an automatic disqualifier.
None of this applies to mutations, and subscriptions ride a different transport entirely — an SSE or WebSocket event has no URL to cache against, which is why realtime paths need their own delivery guarantees rather than cache headers. Deploy the same discipline across layers: the derivation rules in response caching strategy decide what a response's headers say; this topic decides which request URLs the CDN is allowed to store them under.
Practice Problems
A server runs in strict allowlist mode. Given the client request and the manifest state, produce the exact responses for both round trips, then write the registration step that adds a new query to the manifest with the same hash the client will compute.
Example:
Input: request 1: GET /graphql?variables={"id":"p1"} &extensions={"persistedQuery":{"version":1,"sha256Hash":"9b4ac2..."}} manifest: { "9b4ac2...": { document: "query P($id: ID!) { product(id: $id) { name } }" } } request 2 (different client build): same extensions, document not in manifest
Output: request 1 → 200 {"data":{"product":{"name":"Bottle"}}} # hash found, no body sent request 2 → 200 {"errors":[{"message":"PersistedQueryNotFound", "extensions":{"code":"PersistedQueryNotFound"}}]} # strict mode never requests the body, so nothing executes registration: sha256("query P($id: ID!) { product(id: $id) { name } }") = 9b4ac2... → write that key into the manifest shipped with the server
A known hash skips the body entirely — that is the bandwidth and cache win. An unknown hash in strict mode is terminal rather than a handshake: without the two-step retry, a hash-only request can never register itself, which is precisely what makes it a safelist.
Optimal Solution — O(1) lookup per request (O(L) hash computation when a body is uploaded) time, O(M) for M manifest entries space
Generate the manifest in CI from registered documents using one shared normalizer, deploy it with the server, and have the client build import only hashes — then answer not-found for anything absent.
// build step: one normalizer, shared by CI and the client SDK
import { createHash } from 'node:crypto';
export function hashDocument(doc) {
const normalized = doc
.replace(/#[^\n]*/g, '') // strip comments
.replace(/\s+/g, ' ') // collapse whitespace
.trim();
return createHash('sha256').update(normalized).digest('hex');
}
// server middleware (strict mode)
if (!manifest[hash]) {
return json({ errors: [{ message: 'PersistedQueryNotFound',
extensions: { code: 'PersistedQueryNotFound' } }] });
}
return execute(manifest[hash].document, variables);
// client sends: extensions.persistedQuery.sha256Hash + variables only Edge Cases:
- Client and server normalizing differently produces a permanent not-found loop
- Variables containing user identity must not be dropped from the CDN cache key
- A query shipped in a new app release but missing from the manifest fails on 100% of launches, not a fraction
Quiz
1. A client sends a hash-only request and the server has no entry. What happens?
2. Why is APQ alone not a safelist?
3. What belongs in the CDN cache key for a persisted-query GET response?
4. Why does Vary: Authorization usually hurt more than it helps on a GraphQL endpoint?
Flashcards
Question
APQ handshake
Click to reveal answer
Answer
Hash-only GET → PersistedQueryNotFound → client retries with document → server caches hash → 200.
Question
APQ vs safelist
Click to reveal answer
Answer
APQ saves bandwidth but self-registers any document; a safelist executes only manifest hashes built at deploy.
Question
PersistedQueryMismatch
Click to reveal answer
Answer
Body arrived but its sha256 does not equal the declared hash — refuse to execute, it is a client bug or tampering.
Question
CDN key for GraphQL
Click to reveal answer
Answer
query id + canonicalized variables; identity-dependent responses are private/no-store, never Vary-per-token.
Question
Manifest ship order
Click to reveal answer
Answer
Server manifest first, then hash-only clients — reversed order means every request 404s.
Revision Notes
Key Takeaways
- 1. APQ replaces document text with a SHA-256 identity, making GraphQL GET requests short and stable
- 2. Allowlisting is what turns a bandwidth trick into an execution guard: unknown hash means no execution
- 3. Cache keys need canonicalized variables, and personalization belongs in private/no-store rather than Vary
Interview Tips
- • Describe the handshake as two round trips with the exact error code, then say what changes on the third request
- • Answer "how do you cache GraphQL at the CDN?" with the full chain: GET + persisted id, canonical variables, public/private header decision
Cheat Sheet
APQ flow
1. GET ?extensions={"persistedQuery":{"version":1,"sha256Hash":"..."}}
2. 200 {"errors":[{"extensions":{"code":"PersistedQueryNotFound"}}]}
3. retry with &query=<document> + same hash
4. server caches hash → doc, 200 {"data":...}
mismatch body vs hash → PersistedQueryMismatch, do not execute
Hash: sha256 over identical normalized bytes — same normalizer in CI and client build.
Allowlist: manifest built at deploy; unknown hash → not-found, never fetched.
CDN key: query id + sorted-key variables. Public data → public, max-age=N; personalized → private, no-store; never cache responses containing errors[].