Does GraphQL break HTTP caching? Here's what POST gives up.

TL;DR
GraphQL HTTP caching is possible, but a conventional POST /graphql API accepts operations and variables only in the request body. This poses a problem for browsers, proxies, and CDNs, which often create cache keys exclusively from URLs and request headers. A practical workaround is to send queries over GET, which allows URLs to retain caching-related data. Replacing full query text with a persisted document ID keeps URLs short. However, an API doesn't decide what's safe to share: make sure to keep viewer-specific data out of shared caches.
When we fact-checked 18 GraphQL vs REST claims, Claim 8 came back as partially true: GraphQL uses POST to a single endpoint, so HTTP caching doesn't work. The verdict is fair but short, and leaves half the question open. What exactly does POST /graphql give up, what does HTTP actually specify, and what do you have to build to get shared caching back?
This is about HTTP response caching, mostly the shared caches in reverse proxies and CDNs. Browser caches matter too, but they're private, one per user. This post is not about DataLoader, database caching, or normalized client caches, except to say where they fit in the grand scheme of things.
With a conventional POST-based GraphQL API, you don't inherit a per-read cache key. In REST, the URL already gives every resource a starting cache key. In GraphQL, someone has to intentionally decide the request shape and the key on top of the freshness and invalidation work both styles need. (Or something has to. These days, that can be an agent.)
This is a classic example:
The URI identifies the resource. Cache-Control tells caches how long they may reuse it. The ETag lets a cache revalidate with If-None-Match and get back a 304 Not Modified if nothing changed.1 2
This model is excellent when a screen maps to one resource, but it gets messier when a page needs a user, their order history, recommendations, inventory, pricing, and permissions. You could either fan out to a bunch of endpoints and orchestrate on the client, or build an aggregation endpoint whose cache entries grow with every combination of parameter values in the key, the trade-off we worked through under Claims 5 and 8. And when an entity changes, a CDN won't be able to tell which of those entries hold it unless you tagged them yourself.
REST relocates caching complexity rather than removing it. Ahmet Soormally made that argument in April.
RFC 9111 defines the cache key as:
the information a cache uses to choose a response and is composed from, at a minimum, the request method and target URI used to retrieve the stored response2
Content negotiation extends it: a cache incorporates the request headers nominated by the response's Vary field.2
Nothing in HTTP requires the request body in the key for GET or POST. Which is the problem with a GraphQL POST:
To a cache keyed on method and URI, every request to this endpoint looks the same. A product query, a search query, and an authenticated account query are indistinguishable, because the key doesn't include the part that makes them different. Conventional caches pass POST through to the origin anyway.
POST responses can be stored, but the condition is narrow enough that it is a footnote in practice. RFC 9110 §9.3.3:
Responses to POST requests are only cacheable when they include explicit freshness information (see Section 4.2.1 of [CACHING]) and a Content-Location header field that has the same value as the POST's target URI (Section 8.7). A cached POST response can be reused to satisfy a later GET or HEAD request.1
For a GraphQL API where reads are POSTs, the allowance never produces a hit in an ordinary HTTP cache. And when a POST comes back with a non-error (2xx or 3xx) status, a cache it passes through MUST invalidate stored responses for that POST's target URI. That rule is in RFC 9111 §4.4, not in the POST section itself.2 It won't clean up your queries either: /graphql and /graphql?extensions=…&variables=… are different URIs, so a mutation POST to /graphql doesn't automatically invalidate cached GET queries.
"At a minimum" is the floor, not a ceiling, and RFC 9111 adds that "[c]aches might incorporate additional material into the cache key."2 So a CDN can hash a POST body into the key. Serving that stored response to a later POST is another matter: HTTP says "a POST request cannot be satisfied by a cached POST response,"1 and a cache "MUST write through requests with methods that are unsafe."2 The products that cache POST rely on their own rules, not HTTP's ordinary POST reuse, which is why each one is vendor-specific.
| CDN | POST caching | Detail |
|---|---|---|
| CloudFront | Not natively | Caches GET, HEAD, and optionally OPTIONS, and "does not cache responses to requests that use other methods"3 |
| Fastly (VCL) | Build it yourself | GET, HEAD, and PURGE trigger a lookup, everything else passes. req.body is available in VCL for bodies under 8KB, the raw material for a custom cache key. A larger body leaves req.body blank, so custom code has to skip the cache, not hash an empty value4 |
| Cloudflare | Not by default | Documented as a non-caching condition: the request method is anything other than a GET. It doesn't cache JSON by default, even over GET5 |
| Akamai | Supported feature | By default, POST responses pass through uncached. Ships a Cache POST Responses behavior plus a GraphQL-specific caching behavior. The Cache POST Responses behavior is limited to requests of 16KB, and the GraphQL behavior skips its analysis above a 4096-byte query, so larger operations lose the GraphQL-aware handling6 |
Sending queries over GET is the obvious move, since queries are reads.
The GraphQL over HTTP working draft says:
A server MUST support GraphQL-over-HTTP request via POST, and MAY support GraphQL-over-HTTP request via other HTTP methods, such as GET.7
MAY, not SHOULD. GET support is something you check for, or build. graphql.org's serving guide gives the reason to bother: GET lets a client "facilitate HTTP caching or edge caching in a content delivery network (CDN)."8 The spec also prohibits mutations over GET and recommends a 405 if one is attempted, which matches HTTP's semantics for safe methods.
For GET requests, parameters go in the query component as application/x-www-form-urlencoded, and variables and extensions must each be encoded as a JSON string rather than a nested object.7 Abbreviated, with ... standing in for the rest of the document:
Which works, and then runs into practical limits. Long operations produce long URLs. The document and variables become part of the URL, so they show up in access logs and monitoring, and in browser history if someone opens the URL directly. And clients can send any document they like unless the server restricts execution.
RFC 9110 says HTTP "does not have specific length limitations for many of its protocol elements." It only recommends supporting URIs of at least 8000 octets, with 414 (URI Too Long) as the failure mode.9 The ceilings that matter live in your infrastructure. nginx's default buffer limits the request line to 8KB, Apache's LimitRequestLine defaults to 8190 bytes, and CloudFront documents an 8,192-byte URL limit. The nginx and Apache values are defaults you can change.3 9 Chrome allows 2MB.9
GET restores a cacheable request shape. It doesn't give you a maintainable interface.
The pattern that addresses both problems, usually called persisted queries, is to register the operation ahead of time and send an identifier instead of the text. The identifier shortens the URL, though large variables can still push it past a limit. And it only stops arbitrary documents if the server enforces the list.
There are two versions of this and they're routinely conflated.
Build-time persisted documents, also called trusted documents, are registered ahead of time, usually by extracting them from your source at build time, hashing them, and shipping a manifest to the server. Cosmo also accepts custom IDs in place of hashes when APQ is off.12 Once the server enforces it, the server executes only what's in the manifest. Benjie Gillam argues for the name because "it clearly differentiates this use from 'automatic persisted queries' (a bandwidth optimization)."10
Automatic Persisted Queries register at runtime. The client sends a hash, the server responds with a not-found error, the client retries with the hash plus the full document, and the server stores the mapping for next time. From then on the request is hash-only, and can be a GET.
Apollo draws the line in its own comparison table:
APQ doesn't provide safelisting capabilities because the router dynamically populates its APQ cache over time with any operations it receives.11
APQ is a bandwidth and cacheability feature. A persisted document list, once the server enforces it, is a security feature that also happens to help caching. If you tell your security reviewer you've locked down your API with persisted queries, know which one you actually deployed. And even an enforced list only limits which documents run. As Benjie Gillam points out , an attacker "can still take the documents you already have and issue them with their own carefully crafted inputs." Hardcode pagination limits into the documents, or enforce them on the server.10
The cleanest evidence that these are different mechanisms is that you can't run Cosmo's safelist alongside APQ. Cosmo's docs note that the safelist option "cannot be used alongside APQ, as their functions are opposite."12 A safelist rejects anything it wasn't told about ahead of time. APQ accepts any valid operation and remembers it. Those are contradictory instructions, so turn APQ off before you enable the safelist. In Cosmo, enforcing means turning on the safelist or blocking non-persisted operations. The default allows everything.12
The wire shape for APQ over GET is a URL-encoded extensions parameter (hash abbreviated):
Short, stable, and indistinguishable from any other cacheable GET as far as your CDN is concerned. We walked through that setup with the Cosmo Router's APQ and Cache-Control policy in our post on GraphQL edge caching with Cloudflare, Fastly, and Fly.io. With its cache control policy enabled, Cosmo Router sets the client's Cache-Control header from its subgraphs and takes the strictest answer: any no-cache or no-store wins, otherwise the smallest max-age. Mutations always get no-cache.
It does not cache anything by itself: it provides a stable request identity so an HTTP cache can store a response that's eligible for storage. GET makes the request eligible, not cached by default: Cloudflare, for one, doesn't cache JSON responses by default.5 And the identifier only removes the document text from the URL. Variables stay, sensitive ones included.
Persisted documents also aren't in the GraphQL specification.13 Everything interoperable-looking about extensions.persistedQuery.sha256Hash is convention riding on the open extensions request parameter from the GraphQL over HTTP draft, not something core GraphQL defines.7
One rule matters most: the cache key must include every request input that can change the response. Changes to the underlying data are a freshness and invalidation problem, not a key problem. Under-key it and you serve one tenant's data to another. Over-key it and you build a cache with a hit rate near zero.
You express most of these inputs through the URL and Vary, the two levers HTTP gives you. Then check that your CDN uses them. CloudFront keys only on the query parameters and headers its cache policy names, so a hash in the URL does nothing if the policy ignores it.3
| Input | In the shared cache key? | Example |
|---|---|---|
| Persisted document hash | Yes | sha256:ecf4ed… |
operationName, if the document holds more than one operation | Yes | operationName=ProductPage |
| Variables that affect the result | Yes | productId=123 |
| Locale or currency | Yes, when content or pricing differs | locale=en-US, currency=USD |
| Tenant or region | Yes, when data is partitioned | tenant=acme, region=us-west |
| Viewer identity | Prefer private caching; keep out of cross-user sharing | userId=42 |
Authorization header | Extreme care, see below | n/a |
That last row has a spec-level guardrail. RFC 9111 §3.5 says a shared cache MUST NOT use a cached response to a request carrying an Authorization header to satisfy any subsequent request, unless the response carries a directive that allows it: must-revalidate, public, or s-maxage.2 RFC 9111 §3 applies the same opt-in to storing such a response. Either way the default is on your side. Overriding it is a choice and probably the single easiest way to leak one user's data to another.
This guardrail applies to the Authorization request header. A cookie-authenticated response gets no such default, so treat it as personalized until you've proven otherwise. And check that CDN rules don't override your origin. In CloudFront, a minimum TTL above zero caches responses "even if the Cache-Control: no-cache, no-store, or private directives are present in the origin headers."3
For viewer-specific data, pick one:
- Mark it
privateif the browser may cache it but shared caches may not, orno-storeif no HTTP cache should store it. Application-level caches need their own privacy controls. - Split public fields and private fields into separate operations, so the public half stays edge-cacheable. A product page's price and availability can be shared at the edge while "your saved items" stays private.
- Partition by a coarse authorization scope (plan tier, role, region) only when you can state the cardinality and have reasoned about the leakage risk. A role or tenant in the key isn't access control. Authorize the request before serving a protected hit, and take the partition from verified identity, not a value the client supplies.
"GraphQL caching" collapses at least five mechanisms into one phrase, which is why the arguments about it go in circles.
| Layer | What it caches | Best fit | Main limitation |
|---|---|---|---|
| Browser and CDN HTTP cache | Whole operation responses | Repeated reads, with public responses for cross-user sharing | Needs a stable key and correct variation rules |
| Gateway or origin response cache | Query results behind the edge | Repeated server-side reads | Still needs freshness and invalidation design |
| Resolver or entity cache | Expensive lookups and computed fields | Hot data reused across operations | Response assembly still happens |
| Normalized client cache | Entities inside one application | Rich interactive clients | Not shared across users or clients |
| Request-scoped batching (DataLoader) | Duplicate loads within one operation | N+1 mitigation | Not a cache across requests |
GraphQL's own caching page addresses a different problem, stable object identity for client caches: "In GraphQL, there's no URL-like primitive that provides this globally unique identifier for a given object."14 Its suggested best practice is a globally unique identifier, such as an id field, that clients can cache by. That's the normalized-client-cache row, not the CDN row.
That's the fuller answer to Claim 8 in our GraphQL vs REST fact-check. GraphQL doesn't break HTTP caching. It removes the default that made you feel like you'd designed a caching strategy when you'd really just picked a URL scheme. REST hands you that default for resource-shaped reads, and it gets harder to lean on the moment a view needs six resources.
[1] IETF. "RFC 9110: HTTP Semantics" (§8.8.3, §9.3.3, §13.1.2, §15.4.5). https://www.rfc-editor.org/rfc/rfc9110.html
[2] IETF. "RFC 9111: HTTP Caching" (§2, §3, §3.5, §4, §4.1, §4.4, §5.2). https://www.rfc-editor.org/rfc/rfc9111.html
[3] Amazon Web Services. "Cache behavior settings," "Understanding the cache key," and "Quotas" (URL length). https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistValuesCacheBehavior.html, https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/understanding-the-cache-key.html, https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html
[4] Fastly. "vcl_recv", "req.body" and "VCL constraints and limitations." https://www.fastly.com/documentation/reference/vcl/subroutines/recv/, https://www.fastly.com/documentation/reference/vcl/variables/client-request/req-body/, https://www.fastly.com/documentation/reference/vcl/constraints-and-limitations/
[5] Cloudflare. "Default cache behavior." https://developers.cloudflare.com/cache/concepts/default-cache-behavior/
[6] Akamai. "Cache POST Responses" and "GraphQL Caching." https://techdocs.akamai.com/property-mgr/docs/cache-post-responses, https://techdocs.akamai.com/property-mgr/docs/graphql-caching
[7] GraphQL Foundation. "GraphQL over HTTP specification" (draft). https://http-spec.graphql.org/draft/
[8] GraphQL Foundation. "Serving over HTTP." https://graphql.org/learn/serving-over-http/
[9] IETF. "RFC 9110" §2.3, §4.1, §15.5.15 and "RFC 9112: HTTP/1.1" §3. https://www.rfc-editor.org/rfc/rfc9112.html. nginx. "large_client_header_buffers" (default 4 8k; "A request line cannot exceed the size of one buffer, or the 414 (Request-URI Too Large) error is returned to the client"). https://nginx.org/en/docs/http/ngx_http_core_module.html. Apache HTTP Server. "LimitRequestLine" (default 8190). https://httpd.apache.org/docs/2.4/mod/core.html. Chromium. "URL display guidelines" ("Chrome limits URLs to a maximum length of 2MB"). https://chromium.googlesource.com/chromium/src/+/main/docs/security/url_display_guidelines/url_display_guidelines.md
[10] Gillam, B. "GraphQL Trusted Documents." https://benjie.dev/graphql/trusted-documents
[11] Apollo GraphQL. "Safelisting with persisted queries." https://www.apollographql.com/docs/graphos/platform/security/persisted-queries
[12] WunderGraph. "Persisted Operations." https://cosmo-docs.wundergraph.com/router/persisted-queries/persisted-operations
[13] GraphQL Foundation. "GraphQL Specification" (draft). https://spec.graphql.org/draft/
[14] GraphQL Foundation. "Caching." https://graphql.org/learn/caching/
Frequently Asked Questions (FAQ)
No, but a conventional POST /graphql API gives up the thing shared caches rely on. RFC 9111 defines the cache key as, at minimum, the request method and target URI, extended by whatever request headers the response Vary header nominates. A GraphQL POST puts the operation and variables in the request body, which is not part of that key, so the key can't tell one operation from another. Caches may add more to the key, but HTTP still requires them to send POST requests to the origin rather than answer them from a cached POST response. Sending queries over GET makes ordinary HTTP caching possible, and persisted document IDs keep those URLs short. You still need correct cache policies and variation rules.
Only under a narrow condition, and RFC 9111 notes that many caches in common use only cache GET responses. RFC 9110 Section 9.3.3 says a POST response is cacheable only with explicit freshness information and a Content-Location header equal to the POST target URI. Even then, under ordinary HTTP caching, the stored response can only satisfy a later GET or HEAD. Separately, RFC 9111 Section 4.4 requires a cache that receives a non-error response to an unsafe request to invalidate stored responses for that request's target URI.
Not by default. CloudFront caches responses to GET, HEAD, and optionally OPTIONS requests, and does not cache responses to other methods at all. Cloudflare does not cache non-GET requests by default. Fastly passes everything except GET, HEAD, and PURGE, though it exposes request bodies under 8KB in VCL, the raw material for a custom cache key. Among these providers, Akamai offers supported POST caching plus a GraphQL-specific caching behavior. Its Cache POST Responses feature has a 16KB request limit, and the GraphQL behavior skips its analysis for queries above 4096 bytes.
POST is the method every GraphQL over HTTP server must support. Use GET for queries you want HTTP caches to store, ideally with a persisted document ID so the URL stays short. The GraphQL over HTTP draft forbids mutations over GET.
Send queries as GET with a persisted document hash. Set Cache-Control (public, s-maxage) only on responses that are safe to share, and check that the CDN's cache key includes the query string. Keep per-user data private or no-store.
It depends which kind. A build-time persisted document list (also called trusted documents) is a safelist once the server enforces it, because the server then only executes operations you registered ahead of time. Automatic Persisted Queries are not a safelist: the server populates its cache at runtime with any operation a client sends, so APQ cuts bandwidth and helps cacheability without restricting what can execute.
No. The core GraphQL specification doesn't specify a transport. The GraphQL over HTTP working draft states that a server MUST support POST and MAY support other HTTP methods such as GET. GET support is an opt-in you build, not a baseline you can assume. The same draft prohibits mutations over GET and recommends a 405 response if one is attempted.
HTTP defines no universal maximum URI length. RFC 9110 says HTTP 'does not have specific length limitations for many of its protocol elements.' It only recommends supporting URIs of at least 8000 octets, with 414 URI Too Long as the failure mode. The real ceilings are in your infrastructure: nginx caps the request line at its 8KB buffer by default, Apache LimitRequestLine defaults to 8190, and CloudFront documents an 8,192-byte URL limit. Chrome documents a much higher 2MB limit, but the usable limit is the lowest one anywhere along the request path.
Content Manager
Brendan Bondurant is the Content Manager at WunderGraph, owning technical content across GraphQL Federation, API tooling, and developer experience. He partners with leadership on product and company messaging and works cross functionally to align positioning, terminology, and content strategy across channels.

