GraphQL Response Caching with Varnish and CDN Edge Caching
AI generated
{ }
type
GraphQL · Varnish · CDN · Edge Caching · Performance
GraphQL Response Caching
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.

18 min read Varnish VCL · persisted queries · surrogate keys Fastly · Cloudflare · CDN

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.

11. FAQ: GraphQL Response Caching with Varnish and CDN

1Why can't it be cached like REST?
POST bodies all land on the same URL, HTTP caches can't distinguish queries without extra measures.
2Are all queries suitable for GET?
Only pure queries with no side effects. Mutations stay on POST for semantic reasons and aren't cacheable.
3What are persisted queries?
Pre-registered queries referenced via SHA-256 hash, shortening URLs and giving stable cache keys.
4How are personalized responses cached?
Either not at all, or by including the Authorization header in the cache key so no data leaks between users.
5What is a surrogate key?
A tag linking a response to its contained entities, for targeted invalidation without a full flush.
6Which CDN works best?
Fastly for native surrogate keys and instant purge, Cloudflare on enterprise plans, CloudFront with Lambda@Edge.
7How is the cache invalidated on mutations?
The resolver calls a targeted purge for the surrogate key of the changed entity.
8How do you measure success?
Via the X-Cache header showing HIT or MISS, fed into access logs or a monitoring dashboard.
9Does it replace resolver-level Redis?
No, edge caching saves origin requests, Redis saves expensive computations, both complement each other.
10How long should the TTL be?
Depends on change frequency, differentiated per query instead of applied uniformly to all responses.