with Varnish and CDN edge caching
POST requests with a dynamic body aren't readily cacheable by classic HTTP caches, yet that's exactly the default transport for GraphQL. GraphQL response caching with Varnish and CDN edge caching clears this hurdle through GET queries, persisted queries and surrogate keys, so responses land at the edge before they ever reach the origin server.
Table of Contents
- 1. Why GraphQL caching is harder than REST caching
- 2. GET instead of POST: the prerequisite for HTTP caching
- 3. Configuring Varnish VCL for GraphQL queries
- 4. Persisted queries as a cache-key strategy
- 5. Surrogate keys for granular invalidation
- 6. CDN edge caching: Fastly, Cloudflare, CloudFront
- 7. Cache invalidation on mutations: purge strategies
- 8. Measuring cache hit rate and setting headers correctly
- 9. Edge caching vs. application-level caching
- 10. Summary
- 11. FAQ
1. Why GraphQL caching is harder than REST caching
REST APIs cache almost by themselves: every resource has a unique URL, a GET request to /products/123 is idempotent by definition, and HTTP caches like Varnish or a CDN can use the URL directly as a cache key. GraphQL response caching faces a structurally different problem: the default transport is a POST request with the query in the body, and two completely different requests technically land on the same URL, /graphql.
An HTTP cache that only looks at the URL sees no difference between a request for a username and a request for the full order history in GraphQL requests, because both hit the same endpoint and the same HTTP method. Without additional measures, GraphQL response caching therefore stays either completely ineffective or accidentally caches the wrong response for the wrong request.
The solution isn't a single technique but the interplay of several building blocks: GET requests for cacheable queries, a deterministic cache key derived from query and variables, and an invalidation mechanism that surgically clears only affected cache entries on mutations. The following sections build up these blocks step by step.
2. GET instead of POST: the prerequisite for HTTP caching
The first and most important step for GraphQL response caching is switching from POST to GET for every query that isn't a mutation. HTTP caches like Varnish don't cache POST responses by default at all, because POST is semantically treated as non-idempotent. GET requests, on the other hand, are automatically treated as cacheable by any HTTP cache, as long as the right Cache-Control headers are set.
// graphql-get-client.js — sending cacheable queries as GET requests
async function fetchGraphQLCacheable(query, variables) {
const params = new URLSearchParams({
query,
variables: JSON.stringify(variables ?? {}),
})
const res = await fetch(`/graphql?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
})
return res.json()
}
// Mutations always stay on POST, they are never cacheable
async function runMutation(query, variables) {
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
return res.json()
}
The clear separation matters: only pure Query operations move to GET, every Mutation consistently stays on POST. Another practical problem is URL length, complex queries with many fields can exceed the maximum length accepted by some proxies and load balancers, which is why persisted queries, as covered in section 4, are often the better solution here.
3. Configuring Varnish VCL for GraphQL queries
Varnish needs explicit VCL rules to handle GraphQL GET requests correctly, because the default configuration often ignores or normalizes query parameters, which can lead to incorrect cache hits with GraphQL. The basic rule: the full query string, containing both the query and the variables, must be part of the cache key.
# default.vcl — cache GraphQL GET requests, bypass mutations entirely
vcl 4.1;
sub vcl_recv {
# Only GET requests to /graphql are eligible for caching
if (req.url ~ "^/graphql" && req.method == "GET") {
# Full query string (query + variables) becomes part of the cache key
return (hash);
}
# POST requests (mutations) always bypass the cache
if (req.url ~ "^/graphql" && req.method == "POST") {
return (pass);
}
}
sub vcl_hash {
hash_data(req.url);
if (req.http.Authorization) {
# Separate cache entries per auth context to avoid leaking data across users
hash_data(req.http.Authorization);
}
return (lookup);
}
sub vcl_backend_response {
if (bereq.url ~ "^/graphql") {
# Respect Cache-Control headers set by the GraphQL server
set beresp.ttl = 60s;
set beresp.grace = 300s;
}
}
Hashing on Authorization is essential as soon as personalized data is involved, otherwise a user could accidentally receive another user's cached response. For purely public, non-personalized queries, this header can be omitted to increase the cache hit rate.
4. Persisted queries as a cache-key strategy
Persisted queries solve two problems at once: they shorten long query strings down to a short hash, and in doing so provide a much cleaner, more compact cache key for GraphQL response caching. Instead of the full query, only a SHA-256 hash is transmitted, which the server already knows because the query was registered once at build time.
# Instead of transmitting the full query string as a GET parameter:
# GET /graphql?query={ products(limit: 10) { id title price } }
# Persisted queries transmit only a short, stable hash:
# GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"a94a8fe5..."}}
# The hash maps deterministically to this registered query on the server
query ProductList {
products(limit: 10) {
id
title
price
}
}
For GraphQL response caching, the advantage is concrete: a hash is always the same length and contains no variable whitespace or formatting differences that could cause unwanted cache misses with manually assembled query strings. Two clients sending the same logical query with different formatting produce the same cache key with persisted queries, but two different ones with raw query strings.
5. Surrogate keys for granular invalidation
A flat 60-second TTL isn't enough for many use cases where data changes unpredictably. Surrogate keys, also called cache tags, solve this problem by tagging every cached response with the IDs of the entities it contains. When a single entity changes, only the cache entry with the matching surrogate key needs to be purged, instead of flushing the entire cache.
// surrogate-keys.ts — tagging responses with entity IDs for targeted invalidation
import type { GraphQLResponse } from './types'
function buildSurrogateKeys(response: GraphQLResponse): string {
const keys = new Set<string>()
function collectIds(value: unknown): void {
if (Array.isArray(value)) {
value.forEach(collectIds)
} else if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>
if (obj.__typename && obj.id) {
keys.add(`${obj.__typename}:${obj.id}`)
}
Object.values(obj).forEach(collectIds)
}
}
collectIds(response.data)
return Array.from(keys).join(' ')
}
// Response middleware sets the header before Varnish caches the response
export function setSurrogateKeyHeader(res: { setHeader: Function }, response: GraphQLResponse) {
res.setHeader('Surrogate-Key', buildSurrogateKeys(response))
}
If a cached response contains, say, the entities Product:42 and Store:7, a targeted purge call for Product:42 is enough to invalidate exactly that entry, without flushing the cache for every other product or store. Varnish supports this mechanism via the xkey module, Fastly has surrogate keys built in natively.
6. CDN edge caching: Fastly, Cloudflare, CloudFront
While Varnish typically runs directly in front of your own origin server, a CDN like Fastly, Cloudflare or CloudFront distributes cached GraphQL responses geographically across many edge locations. For GraphQL response caching, that means shorter latencies for users far away from the origin data center, since responses get served straight from the nearest edge node instead of traveling across the globe every time.
Fastly natively supports surrogate keys and instant purge, making it particularly well suited for GraphQL response caching with frequent, granular invalidations. Cloudflare offers similar functionality with Cache Tags on enterprise plans, while CloudFront needs a combination of cache behaviors and Lambda@Edge functions for custom cache-key logic. The choice of CDN depends heavily on how granular invalidation needs to be and how many requests per second are expected.
7. Cache invalidation on mutations: purge strategies
Every mutation that changes an entity needs to invalidate its associated cache entries, otherwise the API keeps showing stale data after a user has already made a change. Combining surrogate keys with a purge call directly inside the mutation resolver is the most reliable approach for consistent GraphQL response caching.
// mutation-resolver.ts — purge cache entries tied to the mutated entity
async function updateProductPrice(
_parent: unknown,
args: { id: string; price: number },
ctx: { db: Database; cachePurger: CachePurger }
) {
const updated = await ctx.db.products.update(args.id, { price: args.price })
// Purge only the cache entries tagged with this specific product
await ctx.cachePurger.purgeByKey(`Product:${args.id}`)
return updated
}
This targeted invalidation is far more efficient than a full cache flush after every mutation, which would needlessly ruin the cache hit rate on high-traffic APIs. For Fastly, the purge call goes through the REST API with the surrogate key as a parameter, for Varnish through a special HTTP PURGE request against the affected cache server.
8. Measuring cache hit rate and setting headers correctly
Without measurement, it stays unclear whether GraphQL response caching actually works. Varnish provides direct feedback via the X-Cache: HIT or X-Cache: MISS header, which can easily be piped into access logs or a monitoring dashboard. A low hit rate usually points to a TTL value that's too short, faulty cache-key normalization, or too many personalized fields inside frequently used queries.
It's also important that the GraphQL server itself sets correct Cache-Control headers per query instead of using a single global value for all responses. A query that returns exclusively static product data can tolerate a much longer TTL than a query that includes current stock levels. This differentiation is best computed directly in the resolvers based on the requested fields.
9. Edge caching vs. application-level caching
Varnish and CDN edge caching aren't the only option for making GraphQL APIs faster. Application-level caching with Redis takes a different approach and solves partially different problems.
| Approach | Latency benefit | Granularity | Reduces origin load |
|---|---|---|---|
| Varnish / CDN edge caching | Very high, geographically distributed | Whole response, via surrogate key | Yes, fully |
| Redis application-level cache | Medium, single data center | Per field or resolver | Partially, server still runs |
| In-process in-memory cache | Low, per server instance | Per resolver call | No |
In practice, both approaches complement rather than replace each other: Varnish or a CDN cache whole responses for non-personalized, frequently queried operations at the edge, while Redis caches expensive individual computations or external API calls at the resolver level, which would still occur on a response-level cache miss.
Mironsoft
GraphQL performance, Varnish configuration and CDN architecture
Does your GraphQL API hit the origin on every single query?
We set up GraphQL response caching with Varnish or your CDN, including persisted queries, surrogate keys and automatic invalidation on mutations.
Varnish setup
VCL configuration for GET-based GraphQL queries and surrogate keys
CDN integration
Connecting Fastly, Cloudflare or CloudFront for global edge caching
Invalidation
Building targeted purge strategies right into mutation resolvers
10. Summary
GraphQL response caching with Varnish and CDN edge caching overcomes the structural problem that GraphQL is transported over non-cacheable POST requests by default. GET queries establish the basic prerequisite for HTTP caching, persisted queries deliver compact, deterministic cache keys, and surrogate keys enable granular invalidation of individual entities instead of complete cache flushes.
The biggest effect comes from the interplay: a CDN like Fastly distributes cached responses geographically, Varnish handles fine-grained control right in front of the origin server, and both benefit from precise Cache-Control headers set per query instead of globally. Teams that combine these building blocks consistently reduce origin load and latency noticeably, without compromising on correctness for personalized data.
GraphQL Response Caching — The Essentials at a Glance
GET instead of POST
The basic prerequisite for HTTP caching, mutations consistently stay on POST.
Persisted queries
Short, deterministic hashes instead of long query strings as the cache key.
Surrogate keys
Targeted invalidation of individual entities instead of a full cache flush.
CDN edge caching
Geographically distributed responses cut latency for far-away users.