DEV Community

Cover image for Caching GraphQL POST and QUERY requests: what works, what breaks
Kay Schecker for GraphPilot

Posted on Originally published at graphpilot.io AI-assisted

Caching GraphQL POST and QUERY requests: what works, what breaks

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.

Four requests to /graphql: three reads and one mutation. An ordinary CDN keys them all on POST /graphql, one key for four requests. A GraphQL-aware cache keys each read on its operation and variables, serves them as hits, including one sent as QUERY, and sends the mutation to the origin, which purges what it changed.

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 } }"}
Enter fullscreen mode Exit fullscreen mode

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

Five ways to cache GraphQL POST compared: response cache in the server, persisted queries as GET, APQ as GET, your own edge code, and a managed GraphQL edge cache such as GraphPilot, which is ours.

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-cache caches whole responses by @cacheControl hints. The Envelop and Yoga useResponseCache plugin 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_FOUND and the client retries with the full query. With useGETForHashedQueries the requests go out as GET. 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!
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

What goes into the cache key: operation, operation name, variables and context such as host or language header, hashed into one key. Normalize first so differently formatted documents share one entry, or hash the text as sent so a hit needs no parsing.

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
Enter fullscreen mode Exit fullscreen mode

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

The mutation updateBook with id 1 returns Book 1. The cached books list and book with id 1 are dropped because they hold Book 1; book with id 2 is kept.

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)'; }
Enter fullscreen mode Exit fullscreen mode

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}}"}'
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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.

A request with a token: if the answer does not depend on who asks, it can share one entry marked public; if it does, it needs one entry per user, marked private; if unsure, skip the cache.

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 QUERY yet; a proposal is open.
  • GraphQL Yoga answers QUERY with 405. 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 QUERY keyed 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
Enter fullscreen mode Exit fullscreen mode
x-cache: MISS
x-cache-key: 9bf884ed4fad8c8b
--
x-cache: HIT
x-cache-key: 9bf884ed4fad8c8b
--
x-cache: HIT
x-cache-key: 9bf884ed4fad8c8b
--
Enter fullscreen mode Exit fullscreen mode

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 a Set-Cookie header or with Cache-Control: no-store stay 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.

Top comments (0)