GraphQL Caching at CDN Edge: Query Normalization and Persisted Queries

· David Park · 10 min read

GraphQL's flexibility is its greatest strength and its biggest caching challenge. Every query hits a single /graphql endpoint via HTTP POST, which CDNs treat as uncacheable by default. Unlike REST APIs where each resource lives at a distinct URL that doubles as a natural cache key, GraphQL collapses all data fetching into one opaque endpoint. The result is that organizations running GraphQL APIs often bypass CDN caching entirely, forcing every request back to the origin server and surrendering the latency and cost benefits that edge caching provides.

This guide covers the techniques that make GraphQL cacheable at CDN edge nodes: persisted queries, automatic persisted queries (APQ), query normalization, GET-based request patterns, and response-level cache control. Each approach involves trade-offs between developer experience, cache efficiency, and security that determine which combination fits a given architecture.

Why GraphQL Breaks Traditional CDN Caching

CDN caching relies on two properties of HTTP: the request method determines cacheability (GET is cacheable, POST is not), and the URL serves as the cache key. GraphQL violates both conventions. Standard GraphQL clients send POST requests containing the query string in the request body. The URL is always the same (/graphql), so the CDN sees identical requests regardless of whether the client is fetching a user profile or a product catalog.

The cache key design problem goes deeper than the HTTP method. Two GraphQL queries requesting identical data can look different as strings. Field ordering, whitespace, comments, aliases, and variable naming all create distinct string representations that hash to different cache keys despite producing the same response. Without normalization, even a team using consistent queries across their application will see cache fragmentation as different developers write the same query in slightly different ways.

GraphQL CDN Caching Challenge: POST vs GET Standard GraphQL (Uncacheable) POST /graphql { query: "{ user(id:1) { name } }" } POST /graphql { query: "{ product(id:5) { title } }" } POST /graphql { query: "{ user(id:1) {name} }" } Same URL, POST method → CDN cannot cache Persisted Queries (Cacheable) GET /graphql?extensions={"pq":"abc123"} GET /graphql?extensions={"pq":"def456"} GET /graphql?extensions={"pq":"abc123"} Unique URLs, GET method → CDN cache hit ✓ APQ Registration Flow Client Send hash only CDN Edge Cache lookup MISS Origin Hash not found PersistedQueryNotFound Resend hash + full query Register + respond Future Requests Cache HIT ✓
Figure 1: GraphQL caching challenge and APQ registration flow

Persisted Queries: The Build-Time Approach

Persisted queries solve the caching problem by eliminating dynamic query strings entirely. During the build process, every GraphQL query in the application is extracted, hashed (typically SHA-256), and registered with the server. At runtime, the client sends only the hash identifier instead of the full query text. This transforms GraphQL requests from opaque POST bodies into distinct, cacheable resources.

Performance Comparison: Edge vs Origin Processing
Metric Edge Processing Origin Processing Improvement
Latency (p50)8–15 ms120–300 ms15–20x faster
Cache Hit Ratio85–95%N/AReduces origin load
TLS Handshake1–5 ms (nearby PoP)50–150 ms10–30x faster
Bandwidth Cost$0.01–0.04/GB$0.05–0.12/GB3–5x cheaper
Availability99.99% (multi-PoP)99.9% (single DC)10x fewer outages

The implementation involves three components: a build-time extraction tool that scans source code for GraphQL operations, a manifest file mapping hashes to query strings, and a server configuration that resolves hashes to their registered queries. Apollo Client, Relay, and urql all support persisted queries through plugins or built-in configuration.

// Build-time: extract and hash queries
// manifest.json generated by build tool
{
  "abc123def456": "query GetUser($id: ID!) { user(id: $id) { name email avatar } }",
  "789ghi012jkl": "query GetProduct($slug: String!) { product(slug: $slug) { title price inventory } }"
}

// Runtime: client sends hash via GET
// GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123def456"}}&variables={"id":"42"}

// CDN sees a unique, cacheable URL for each query+variables combination
// Cache key: /graphql?extensions=...&variables=...

The critical benefit for CDN caching is that persisted queries naturally use GET requests. The query hash and variables are URL parameters, giving each unique query a distinct URL that CDNs cache like any static resource. The Cache-Control headers on the response control TTL exactly as they would for REST endpoints.

Security Benefits of Persisted Queries

Beyond caching, persisted queries create a security allowlist. The server only executes queries that were registered at build time, rejecting arbitrary queries from attackers. This eliminates entire categories of GraphQL attacks: query complexity attacks (deeply nested queries that exhaust server resources), introspection-based reconnaissance, and injection through dynamically constructed query strings. For APIs exposed through a CDN, this allowlist can be enforced at the edge before the request ever reaches the origin.

Automatic Persisted Queries (APQ)

APQ removes the build-time registration requirement through a negotiation protocol. The client computes the SHA-256 hash of the query string and sends it to the server without the full query. If the server recognizes the hash (from a previous request), it executes the query and returns the response. If not, it returns a PersistedQueryNotFound error, and the client retries with both the hash and the full query string. The server stores this mapping for all future requests.

The negotiation adds one extra round trip for the first request with any given query, but all subsequent requests benefit from the smaller payload and cacheability. In practice, this initial penalty is negligible because most applications use a finite set of queries that get registered within the first minutes of traffic after a deployment.

// Step 1: Client sends hash only
POST /graphql
{
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  },
  "variables": { "id": "42" }
}

// Step 2: Server responds with error (first time only)
{
  "errors": [{ "message": "PersistedQueryNotFound" }]
}

// Step 3: Client retries with full query
POST /graphql
{
  "query": "query GetUser($id: ID!) { user(id: $id) { name email avatar } }",
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  },
  "variables": { "id": "42" }
}

// Step 4: All future requests use GET with hash only
// GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"ecf4edb..."}}&variables={"id":"42"}

APQ works well with CDN edge caching when configured to use GET requests after registration. The CDN layer sees the negotiation POST requests pass through to origin, but all subsequent GET requests hit the edge cache. For high-traffic APIs, the cache hit ratio approaches that of fully persisted queries within seconds of deployment.

Query Normalization for Cache Efficiency

Even with persisted queries, cache efficiency depends on producing consistent cache keys for semantically identical requests. Query normalization transforms varied query representations into a canonical form before hashing. Without normalization, the following three queries would produce three different cache keys despite requesting identical data:

// Query A: original developer style
query {
  user(id: "42") {
    name
    email
    avatar
  }
}

// Query B: different field order, compressed
query{user(id:"42"){avatar email name}}

// Query C: with alias and fragments
query GetUser {
  u: user(id: "42") {
    ...UserFields
  }
}
fragment UserFields on User { name email avatar }

// After normalization, all three become:
// query{user(id:"42"){avatar email name}}
// Same hash → same cache key → cache HIT

Normalization typically involves: sorting fields alphabetically within each selection set, removing whitespace and comments, inlining fragments, stripping aliases that do not affect the response shape, and normalizing variable definitions. The normalization can happen at the client (before sending), at the CDN edge (using an edge function), or at the origin server (before cache key computation).

Query Normalization Pipeline Parse AST Query → tree Sort Fields Alphabetical order Inline Frags Expand fragments Strip & Print Minify + serialize Hash SHA256 Impact on Cache Hit Ratio Without normalization: 45-60% hit ratio | With normalization: 85-95% hit ratio | Improvement: 40-50 percentage points Variable Separation Strategy Cache key = normalized_query_hash + sorted_variables_hash | Enables partial cache sharing across variable combinations
Figure 2: Query normalization pipeline and its impact on cache efficiency

Edge Function Middleware for GraphQL Caching

The most flexible approach uses edge functions (Cloudflare Workers, Lambda@Edge, Deno Deploy) to implement a caching middleware layer between the client and origin. The edge function intercepts GraphQL requests, normalizes the query, computes the cache key, checks the edge cache, and either returns the cached response or forwards to origin.

// Cloudflare Worker: GraphQL edge caching middleware
export default {
  async fetch(request, env) {
    if (request.method === 'POST') {
      const body = await request.json();
      const { query, variables, extensions } = body;

      // Check for persisted query hash
      let cacheKey;
      if (extensions?.persistedQuery?.sha256Hash) {
        cacheKey = extensions.persistedQuery.sha256Hash;
      } else if (query) {
        // Normalize and hash the query
        const normalized = normalizeQuery(query);
        cacheKey = await sha256(normalized);
      }

      if (cacheKey) {
        const varHash = variables ? await sha256(JSON.stringify(sortKeys(variables))) : '';
        const fullKey = `graphql:${cacheKey}:${varHash}`;

        // Check KV cache
        const cached = await env.GQL_CACHE.get(fullKey);
        if (cached) {
          return new Response(cached, {
            headers: {
              'Content-Type': 'application/json',
              'X-Cache': 'HIT',
              'Cache-Control': 'public, max-age=60'
            }
          });
        }

        // Forward to origin
        const response = await fetch(env.ORIGIN_URL, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body)
        });
        const responseBody = await response.text();

        // Cache successful responses (no errors)
        const parsed = JSON.parse(responseBody);
        if (!parsed.errors) {
          await env.GQL_CACHE.put(fullKey, responseBody, { expirationTtl: 60 });
        }

        return new Response(responseBody, {
          headers: { 'Content-Type': 'application/json', 'X-Cache': 'MISS' }
        });
      }
    }
    // Fallback: forward to origin
    return fetch(request);
  }
};

This middleware pattern provides granular control over cache behavior. Different query types can receive different TTLs: product catalog queries might cache for 5 minutes, while user profile queries cache for 30 seconds. The edge function can also implement request coalescing to prevent cache stampedes when a popular query's TTL expires simultaneously across thousands of concurrent requests.

Response Cache-Control for GraphQL

The GraphQL specification does not define caching semantics, leaving servers to implement their own Cache-Control strategies. The most effective approach calculates the response's max-age from the minimum TTL across all resolved fields. If a query fetches both a product (cacheable for 300 seconds) and its real-time inventory count (cacheable for 10 seconds), the response max-age should be 10 seconds because the inventory data expires first.

// Server-side: calculate response cache headers from field-level directives
// Schema with cache hints:
// type Product @cacheControl(maxAge: 300) {
//   title: String
//   price: Float @cacheControl(maxAge: 60)
//   inventory: Int @cacheControl(maxAge: 10)
// }

// Response headers computed dynamically:
// Cache-Control: public, max-age=10
// (minimum across all resolved fields)

Apollo Server implements this through the @cacheControl directive and the CacheControlExtensionsPlugin. The plugin walks the resolved field tree, collects cache hints, and sets the response Cache-Control header to the most restrictive value. This header propagates through the CDN layer, where the TTL optimization logic applies standard HTTP caching rules.

Cache Invalidation Strategies

GraphQL's type system provides a natural foundation for cache invalidation. Every response can be tagged with the entity types and IDs it contains. When a mutation modifies an entity, the server (or edge function) can invalidate all cached responses containing that entity. This is more precise than URL-based invalidation and avoids the need to purge the entire GraphQL cache.

Tag-based invalidation works by attaching metadata to cached responses: User:42, Product:789, Order:1234. When a updateUser(id: 42) mutation executes, the cache invalidation system purges all entries tagged with User:42. CDNs like Fastly support surrogate keys natively, while Cloudflare Workers can implement this pattern using KV or Durable Objects for the tag-to-key mapping.

Performance Comparison: Caching Strategies

StrategyCache Hit RatioSetup ComplexitySecurityBest For
No caching (POST only)0%NoneStandardDevelopment only
GET with query param40-60%LowQuery exposurePublic APIs
APQ70-85%MediumGoodMost applications
Persisted queries85-95%High (build step)Excellent (allowlist)Production APIs
Edge function + normalization90-98%HighConfigurableHigh-traffic APIs

Production Implementation Checklist

Deploying GraphQL caching at CDN edge requires coordinating changes across the client, server, and CDN configuration. Start with APQ for the lowest barrier to entry, then migrate to fully persisted queries as the system matures. Add query normalization when cache analytics reveal duplicate queries with different string representations.

Configure CDN monitoring to track GraphQL-specific metrics: cache hit ratio per operation name, cache key cardinality, normalization effectiveness (percentage of queries that normalize to an existing key), and mutation-triggered invalidation frequency. These metrics reveal whether the caching layer is performing as expected and where further optimization is possible.

Variable handling deserves particular attention. The cache key must include both the query hash and a deterministic hash of the variables. Sorting variable keys before hashing prevents {"id":"42","limit":10} and {"limit":10,"id":"42"} from producing different cache keys. For queries with user-specific variables, consider whether the response is truly user-specific or if it can be shared across users by excluding the authentication context from the cache key.

External References

References & Standards: OWASP CDN Security · HTTP/2 specification