Making GraphQL Responses CDN-Cacheable with GET Requests
This guide, part of GraphQL vs REST API Tradeoffs, removes the biggest practical disadvantage of GraphQL for content delivery: responses that CDNs cannot cache. It shows how to send queries as GET requests with short, stable URLs, set cache headers and tags on the responses, keep preview traffic out of the cache and purge precisely when content changes.
By default, GraphQL clients send every query as a POST with the query text in the body. CDNs do not cache POST requests, because POST is not a safe method and the body is not part of the cache key. The GraphQL over HTTP specification allows queries, but not mutations, to be sent as GET requests with the query and variables in the URL. Combined with persisted queries, which replace the query text with a hash, this produces URLs that CDNs cache like any REST endpoint.
The Problem
An e-commerce site moved its product pages to GraphQL and saw origin traffic triple compared with the old REST setup. Its CDN had served about 85 percent of REST responses from the edge; with GraphQL POST requests, it served none. The GraphQL server, a BFF combining the CMS with pricing data, scaled up to cope, and latency for users far from the origin region grew by several hundred milliseconds. The team did not want to go back to REST, because the component-driven pages had become much simpler with one query each.
How Cacheable GraphQL Works
Three pieces are needed.
GET requests for queries. The client sends queries as GET /graphql?extensions=...&variables=...&operationName=.... Mutations still use POST. Most GraphQL clients support this with a single option, for example useGETForQueries in Apollo Client’s HTTP link or useGETForHashedQueries with the persisted queries link.
Persisted query hashes. Sending full query text in a URL makes URLs long, which some CDNs and proxies reject, and allows anyone to send arbitrary queries through the cache. Persisted queries replace the text with a SHA-256 hash of the query. With automatic persisted queries, the server learns unknown hashes on first use; with trusted documents, the hashes are registered at build time and unknown ones are rejected, which is also a security control.
Cache headers and tags. The server sets Cache-Control on responses, for example public, s-maxage=300, stale-while-revalidate=86400, and adds cache tags naming the entries in the response, so publishes can purge exactly the affected responses. Responses with errors or personalized data are marked private, no-store.
Implementation
On the client, enable persisted queries with GET. The example uses Apollo Client; urql and other clients have equivalent options.
// lib/apollo.ts
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
const persisted = createPersistedQueryLink({ sha256, useGETForHashedQueries: true });
export const client = new ApolloClient({
link: persisted.concat(new HttpLink({ uri: "/graphql" })),
cache: new InMemoryCache(),
});
On the server, set cache headers per response based on what it contains. The plugin below, for GraphQL Yoga, collects entry ids that resolvers register while resolving, and turns them into a surrogate key header along with Cache-Control.
// graphql/cache-headers-plugin.ts
import type { Plugin } from "graphql-yoga";
export interface CacheContext { request: Request; cacheTags: Set<string>; uncacheable: boolean; preview: boolean }
// Headers decided during execution, handed to onResponse through the request object.
const pending = new WeakMap<Request, Record<string, string>>();
export function cacheHeadersPlugin(): Plugin<CacheContext> {
return {
onExecute({ args }) {
return {
onExecuteDone({ result }) {
const ctx = args.contextValue;
const hasErrors = "errors" in result && !!result.errors?.length;
const cacheable = !ctx.preview && !ctx.uncacheable && !hasErrors && args.document.definitions.every(
(d) => d.kind !== "OperationDefinition" || d.operation === "query",
);
pending.set(ctx.request, cacheable
? { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=86400", "Surrogate-Key": [...ctx.cacheTags].join(" ") }
: { "Cache-Control": "private, no-store" });
},
};
},
onResponse({ request, response }) {
for (const [k, v] of Object.entries(pending.get(request) ?? { "Cache-Control": "private, no-store" })) response.headers.set(k, v);
},
};
}
// In a resolver: ctx.cacheTags.add(`product:${product.id}`);
The exact hook names differ between GraphQL servers, but the logic is the same everywhere: queries only, no errors, no preview, no personalized fields, and tags for every entry in the response. The webhook handler then purges by tag using the CDN’s API, as in the webhook-triggered rebuilds section.
Keeping preview and personalization out
Preview requests must never be cached at the CDN. Route them to a different path or host, or make the server detect the preview token or draft mode cookie and mark responses private, no-store, as the plugin does. The same applies to any field that depends on the user, such as a price for a logged-in customer group: resolvers for such fields set ctx.uncacheable = true, which switches the whole response to no-store. A cleaner design moves personalized data into a separate query, so the shared part of the page stays cacheable.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Method for queries | GET | CDNs cache GET, not POST. |
| Query identity | persisted hash (trusted documents in production) | Short URLs, and only known queries reach the origin. |
Cache-Control |
public, s-maxage=300, stale-while-revalidate=86400 |
Fresh for five minutes, served stale while refreshing. |
| Tags | one per entry in the response | Precise purges on publish. |
| Errors and preview | private, no-store |
Never cache failures or drafts. |
| Cache key | full URL including variables | Different variables are different responses. |
Gotchas & Edge Cases
- Variable ordering.
{"id":42,"locale":"en"}and{"locale":"en","id":42}are different URLs and different cache entries. Most clients serialize consistently, but normalize if several clients call the same queries. - URL length limits. Even with hashes, large variable objects can exceed CDN URL limits. Keep variables small, and fall back to POST for the rare query that cannot be.
- Vary headers. If the server varies responses by a header such as
Accept-Language, include it in the CDN cache key or pass the locale as a variable instead, which is simpler. - Cached errors. A transient CMS failure cached for five minutes becomes a five-minute outage. Make sure every response with errors is marked
no-store.
Worked Example
The e-commerce team switched its client to persisted queries over GET, registered its 64 production queries as trusted documents at build time, and added the cache headers plugin with product, category and price-list tags. Prices were split into a separate small query for logged-in customers. The CDN hit rate for GraphQL traffic rose from zero to 88 percent within a day, origin requests dropped by 85 percent, and latency for distant users returned to what it had been with REST.
Choosing Cache Lifetimes
Cache lifetimes are a trade-off between freshness and origin load, and tag purges change that trade-off. With reliable purges on publish, responses can be cached for a long time, because changes remove them immediately; the s-maxage then only limits how long a missed purge can leave stale data. Without tag purges, lifetimes must be short, since they are the only way content updates. A practical setup is a moderate s-maxage of a few minutes, a long stale-while-revalidate so users never wait for a refresh, and tag purges for publishes. Monitor the age of responses served from the edge, which most CDNs expose as a header, and alert if content older than the expected maximum is being served, which usually means purges are failing.
Rollout Checklist
- Enable GET for queries in the client, with persisted query hashes.
- Register trusted documents at build time and reject unknown hashes in production.
- Set
Cache-Controland tags per response, withno-storefor errors, preview and personalized data. - Purge tags from the publish webhook handler.
- Split personalized fields into separate queries so shared data stays cacheable.
- Monitor hit rate, response age and purge failures.
Frequently Asked Questions
Does the CMS’s own GraphQL API support GET?
Many do, and some already set cache headers on delivery responses. Check the documentation; if it does, you may only need to enable GET in the client and add purges.
Are automatic persisted queries enough?
They make URLs short and cacheable, but anyone can still register new queries. Trusted documents registered at build time add protection against arbitrary queries, as described in persisted queries for secure endpoints.
What about client-side caching?
Normalized client caches such as Apollo’s remain useful for navigation within the app, where they avoid refetching entities already on screen. CDN caching serves the first load and other users; both work together.
How do we debug what the CDN cached?
Log the persisted query hash and variables with each origin request, and use the CDN’s response headers for cache status and age. Together they tell you which query produced a response, when it was cached and whether a purge reached it.
Can mutations ever use GET?
No. The GraphQL over HTTP specification requires mutations to use POST, and caching them would be wrong anyway.