Take a bookshop with a GraphQL API. Most of its traffic is reads: the book list on the start page and the page of a single book, requested over and over, while a title or a price changes a few times a day. Each of those reads runs the resolvers and queries the database again for an answer that has not changed. So you look for a cache that takes this repeated work off both the GraphQL server and the database.
In short, a cache for GraphQL POST or QUERY requests has to read the body. It parses the document to tell queries from mutations, keys each query on its text, operation name and variables, keeps answers for logged-in users out of shared entries unless the schema marks them public, and clears cached answers when a mutation changes the data they contain.
Why a CDN cannot cache GraphQL POST requests
RFC 9111 builds the cache key from the request method and the target URI; the body is not part of it. GraphQL moves the query into the body, so every request goes to /graphql and a URL-keyed cache sees one and the same request:
{"query":"{ books { id title author } }"} {"query":"query BookById($id: ID!) { book(id: $id) { id title } }","variables":{"id":"1"}} {"query":"mutation { updateBook(id: \"1\", title: \"New\") { id title } }"} RFC 9110 lets a cache store a POST response only with explicit freshness and a Content-Location equal to the request URI, which GraphQL servers do not send, and even then the stored response may answer a later GET or HEAD, never another POST. A POST may also change things, so a cache that guessed wrong would replay a write. The way around it is to move the query into the URL or to make the cache read the body.
Your options
| Option | Answers from the edge | Purge per object | Client change | Watch out |
|---|---|---|---|---|
| Response cache in the server (Apollo Server, Envelop/Yoga) | No | Only in some plugins | No | Every request still reaches your server |
Persisted queries as GET (trusted documents plus any CDN) | Yes | No | Yes | Build step, purge only by URL |
APQ as GET (Apollo Client plus any CDN) | Yes | No | Yes | Any client can register a hash |
| Your own edge code (Cloudflare Workers, Fastly Compute) | Yes | Yours to build | No | Parsing, keys, scopes and purges are yours |
| Managed GraphQL edge cache (such as GraphPilot, which is ours) | Yes | Yes | No | Another vendor in the request path |
A few notes on the rows:
- Apollo Server's
@apollo/server-plugin-response-cachecaches whole responses by@cacheControlhints. The Envelop and YogauseResponseCacheplugin also invalidates by type and id when a mutation returns that object. Both store in memory by default and need Redis or similar once you run more than one instance. - With persisted queries the client sends a hash in the URL, and the server can refuse every operation it does not know. Invalidation is limited to expiry or purging a URL, so a changed book stays stale in every list until its TTL runs out.
- APQ skips the build step: unknown hashes get
PERSISTED_QUERY_NOT_FOUNDand the client retries with the full query. WithuseGETForHashedQueriesthe requests go out asGET. APQ shrinks requests; it does not restrict which operations run. - Cloudflare's example for caching POST requests shows the hashing part and stores every
POST, mutations included. - GraphPilot keys on the operation and its variables and purges what a mutation made stale (automatic invalidation). The purge only follows writes that pass through it: an admin tool talking straight to the database clears nothing on its own.
Data that is different for every user may need no shared cache at all; a normalized client cache in Apollo Client, urql or Relay already avoids repeat requests on one device.
Whichever you pick, the hard parts are the same: telling reads from writes, building the key, keeping private data private and clearing what a write changed. The small cache below handles each one.
The setup
The demo lives in the graphql-post-caching folder of our examples repository: origin/ is a GraphQL Yoga server with an in-memory bookshop and no cache, educational-cache/ is a hand-written cache meant to be read (not production code), and graphpilot/ holds a compare.sh for an optional edge comparison.
type Book @cacheControl(maxAge: 30, scope: PUBLIC) { id: ID! title: String! author: String! category: String! "Price in cents" price: Int! } type Query { books(category: String): [Book!]! book(id: ID!): Book } type Mutation { updateBook(id: ID!, title: String!): Book! } Four books are seeded at start-up, and a restart resets them. The origin sends no Cache-Control header and ignores @cacheControl; the directive is there for caches that read the schema. You need Node.js 22.15 or later and no account:
git clone https://github.com/GraphPilot/examples.git cd examples/graphql-post-caching npm install npm run origin # the bare origin on http://127.0.0.1:4000/graphql npm run educational # the educational cache on http://127.0.0.1:4100/graphql Build a cache that reads GraphQL
1. Send a read
Send {"query":"{ books { id title author } }"} twice to port 4000 and the origin does the work twice. Send it twice to port 4100 and the second answer carries x-cache: HIT (the x-cache headers are the educational cache's own).
2. Parse to find the operation type
Checking whether the body starts with query fails both ways: { books { id } } is a query without the keyword, a document can hold a query and a mutation with operationName picking one, and a leading comment hides the keyword. Ask the parser:
import { parse, getOperationAST } from "graphql"; function operationType(document, operationName) { const operation = getOperationAST(parse(document), operationName); return operation?.operation; // "query", "mutation", "subscription" or undefined } The cache stores nothing unless this returns query. Invalid documents and introspection go straight to the origin uncached.
3. Build a key that separates what can differ
The key has to include the operation, the operation name, the variables, and any request context the origin reads (path, a storefront in the hostname, a currency or locale header). If the answer depends on it and the key does not, the German and the US storefront share an entry.
The educational cache normalizes: it parses the document, keeps the selected operation and its fragments, prints it in canonical form and hashes that with the coerced variables, host and path. GraphPilot hashes the query text exactly as sent (key format), with a variables hash that ignores member order. Differently formatted documents are then two entries, but a hit costs no parsing. Persisted queries settle the question for both designs, since every client sends the same hash.
4. Store only what is safe to share
// Pseudocode of educational-cache: policy first, storage second if (!isEligiblePublicQuery(request)) return origin(request); // x-cache: BYPASS const key = buildCacheKey(request); const hit = cache.get(key); if (hit && hit.expiresAt > Date.now()) return hit.response; // x-cache: HIT const response = await origin(request); if (isSafeToStore(response)) cache.set(key, response); // bounded, LRU return response; // x-cache: MISS isEligiblePublicQuery skips anything with Authorization or Cookie, mutations, introspection and any query selecting a field missing from a default-deny allowlist. isSafeToStore refuses a non-200 status, a GraphQL errors array, Set-Cookie, and Cache-Control: private or no-store. Entries live 5 seconds by default and the store holds 100.
5. Run a mutation and look at the list
After updateBook(id: "1", ...), both book(id: "1") and the books list hold an old title. A TTL-only cache serves both until they expire. The educational cache reads the Book in the mutation's response and drops every entry that contained that id, list included. Caches that do this at scale use surrogate keys such as Book:id:1. Neither approach can clear a list that never held the object: a create needs one extra key on the list field that every create purges.
Five problems you will run into
The educational cache starts with a 5 second TTL and an artificial 400 ms origin delay, so a hit is visibly faster than a miss; if you type the commands by hand, start it with CACHE_TTL_SECONDS=30 npm run educational. Your keys will match ours, your timings will not. Helper:
URL=http://127.0.0.1:4100/graphql req() { curl -sS -o /dev/null -D - -w 'time_total=%{time_total}\n' "$URL" \ -H 'content-type: application/json' "$@" | grep -iE '^(x-cache|age|time_total)'; } 1. The same query, written differently.
req --data '{"query":"{ books { id title author } }"}' req --data '{"query":"{ books { id title author } }"}' req --data '{"query":"{books{id title author}}"}' x-cache: MISS x-cache-key: 9bf884ed4fad8c8b x-cache-ttl: 5 time_total=0.449 x-cache: HIT x-cache-key: 9bf884ed4fad8c8b x-cache-ttl: 5 age: 0 time_total=0.003 x-cache: HIT x-cache-key: 9bf884ed4fad8c8b x-cache-ttl: 5 age: 0 time_total=0.003 The third request has no spaces and still hits because this cache normalizes. A cache that hashes the text as sent would store a separate entry.
2. Variables and inline values. book(id: $id) with id 1 and id 2 got keys 3b21d1951c9d86b1 and e20824722af7cec0, and id 1 hit on the way back. The same book as an inline literal, book(id: "1"), missed with key 448613b71adbc3db: same data, different document.
3. Data that changes behind the cache's back. With a 5 second TTL, the list missed, hit after 3 seconds (x-cache-ttl: 2, age: 3) and missed again after 6. A TTL is the only freshness guarantee for changes the cache never sees, such as an admin tool writing straight to the database.
4. A write that leaves stale answers behind.
x-cache: MISS x-cache: BYPASS x-cache-reason: mutation x-cache-invalidated: 5 x-cache: MISS (book 1 now reads "Updated Book Title") The mutation removed five entries, every stored response containing book 1, and the next list read showed the new title without waiting for a TTL.
5. Logged-in users. Requests with authorization: Bearer demo or cookie: session=abc both returned x-cache: BYPASS with x-cache-reason: authenticated. The cache cannot know whether the answer was computed for that caller.
Caches that read @cacheControl decide by the schema instead: in Apollo Server's response cache a PUBLIC response is shared, and a PRIVATE one is only cached per session when you configure a session id.
Try an edge cache on the same demo
graphpilot/compare.sh runs the same requests against the bare origin and a GraphPilot service and prints the gp-cache status of each step. It needs the origin on a public URL (Render or a tunnel) and a service set up as in the quickstart. Watch the step that changes a book directly at the origin: no cache in front can see that write.
Caching GraphQL QUERY requests
RFC 10008, published in June 2026, defines QUERY: a safe, idempotent method with a body, and the RFC says the request content belongs in the cache key. Support is another matter:
- The GraphQL-over-HTTP draft does not define
QUERYyet; a proposal is open. - GraphQL Yoga answers
QUERYwith405. Opt-in support was merged on 2026-11-11 and is not in a release yet. - We found no documentation from Cloudflare, Fastly, Akamai or Amazon CloudFront that caches
QUERYkeyed on the body out of the box. - Every client, proxy and server on the path has to let the method through; NGINX support is still a pull request.
The educational cache accepts QUERY for reads, keys it like the same POST and forwards it as POST:
for m in POST POST QUERY; do curl -sS -o /dev/null -D - -X "$m" http://127.0.0.1:4100/graphql \ -H 'content-type: application/json' \ --data '{"query":"{ books { id title author } }"}' | grep -i -e '^x-cache:' -e '^x-cache-key:' echo -- done x-cache: MISS x-cache-key: 9bf884ed4fad8c8b -- x-cache: HIT x-cache-key: 9bf884ed4fad8c8b -- x-cache: HIT x-cache-key: 9bf884ed4fad8c8b -- The QUERY has the same key as the POST and is answered from its entry. A mutation sent as QUERY gets 405 Method Not Allowed with x-cache-reason: unsafe-operation-over-query before it reaches the origin. QUERY leaves invalidation where it was: it says a read is a read, but nothing about which cached answers a later mutation made wrong.
Before you turn a shared cache on
Whatever you put in front of your API, test these points with real requests before you look at
response times:
- Only queries are cached. Mutations and subscriptions always go to your server, and so do introspection queries (
__schema,__type). - Two requests that can get different answers never get the same cache key. Besides the query and its variables, that includes anything else your server reads, such as the storefront in the hostname or the language header.
- Data for logged-in users never ends up in a shared entry, unless your schema says it is the same for everyone. Otherwise the cache skips the request or keeps one entry per user.
- Error responses are not cached: answers with GraphQL
errors, with aSet-Cookieheader or withCache-Control: no-storestay out. - The cache has a size limit, so it cannot grow until it runs out of memory.
- After an update, every cached answer that contains the changed book is gone, the book list included. You also know what clears a cached list after a new book is created.
- If you want clients to use
QUERY, every proxy and server on the way accepts it.
See it in under a minute
How a write clears the right answers:
Why a new book can miss a cached list:
Who may see which answer:
The full post has every command and output in detail. The code is in GraphPilot/examples.





