Accelerating GraphQL with Persisted Queries: Caching, APQ, and Real-World Wins
Boost GraphQL performance with persisted queries: smaller payloads, CDN caching, observability, and security—with rollout tips and pitfalls.
Image used for representation purposes only.
Overview
GraphQL persisted queries are a powerful and practical way to speed up APIs by shrinking payload sizes, unlocking edge caching, and tightening security. Instead of sending the full query text with every request, the client sends a stable identifier (usually a SHA-256 hash) that maps to a pre-registered operation on the server. This simple shift can cut bandwidth, reduce parsing workload, enable CDN caching on GET, and make traffic easier to analyze and protect.
This article explains how persisted queries work, why they improve performance, how to deploy them safely at scale, and what pitfalls to avoid.
What exactly are persisted queries?
A persisted query is a server-known operation identified by a deterministic key. There are two main flavors:
- Pre-registered persisted queries: You upload a manifest (id → query text, operationName) to the server during your build/deploy. Requests send only the id and variables.
- Automatic Persisted Queries (APQ): The client sends the id; if the server misses, it responds with an error indicating it wants the full text. The client retries with the full query, and the server stores the mapping for future hits.
Persisted queries are typically addressed via a SHA-256 of the query text (often with whitespace-normalized, minified text). Variables are not included in the hash; they are supplied per-request.
Why they’re faster
- Smaller payloads: Sending a 64-byte hash instead of a multi-kilobyte query saves bandwidth in both directions. This is especially impactful on mobile networks and chatty UIs.
- Less CPU on the server: You parse and validate each distinct query once. Subsequent requests skip parsing/validation and often hit compiled/planned execution paths.
- Edge caching: With GET requests and a stable cache key, CDNs can cache query responses, slashing origin load and TTFB for repeated queries.
- Better observability: Stable operation ids and names make metrics, alerts, and cost attribution simpler and faster.
Request flow examples
Pre-registered persisted query (ideal for prod)
Client sends a GET to leverage CDN caching:
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"3b9a...c0"}}&variables={"id":"123"}
Host: api.example.com
Accept: application/json
The server looks up sha256Hash → query text, executes with variables, and returns:
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=30
Content-Type: application/json
{"data":{"user":{"id":"123","name":"Ada"}}}
APQ miss-then-retry (good for gradual rollout)
- Client tries the id only:
POST /graphql
Content-Type: application/json
{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"3b9a...c0"}},"variables":{"id":"123"}}
-
Server doesn’t have it yet, replies with a hint (e.g., PersistedQueryNotFound).
-
Client retries with the full text to seed the mapping:
POST /graphql
Content-Type: application/json
{"query":"query GetUser($id: ID!){ user(id:$id){ id name } }","operationName":"GetUser","extensions":{"persistedQuery":{"version":1,"sha256Hash":"3b9a...c0"}},"variables":{"id":"123"}}
From then on, the id-only form is sufficient.
GET vs POST and CDN caching
- Use GET for id-only persisted queries whenever the response is cacheable (queries only; never mutations). GET unlocks straightforward CDN caching.
- Use POST for APQ retries or for non-cacheable operations.
- Ensure the CDN cache key includes the full request URL (including both extensions and variables). Many CDNs do this by default for GETs, but verify.
Tip: GraphQL variables must be in the URL for GET. Canonicalize the query string to avoid accidental cache fragmentation (e.g., consistent key ordering and encoding for extensions and variables).
Designing a robust cache key
A good edge cache key for a persisted query response should include:
- Operation id (sha256Hash)
- Variables (serialized in a canonical order)
- Version marker (see “versioning” below)
- Any headers that affect the representation (e.g., Accept-Language)
Avoid including highly-variant headers like Authorization in the cache key for public data. For private data or per-user personalization, do not cache publicly; use Cache-Control: private or bypass caching.
Managing variables and cache fragmentation
Variables are often the main source of cache misses. Strategies:
- Normalize variable serialization (sorted keys, consistent encoding) so the same logical variables produce the same URL.
- Split cacheable public data (e.g., product details by id) from private or highly dynamic selections. Serving public data via persisted queries over GET yields the biggest win.
- Consider separate persisted queries for distinct usage patterns (e.g., list vs detail) rather than one mega-query with dozens of optional variables.
Compression, HTTP/2/3, and TLS
- Even with gzip/brotli, removing kilobytes of query text matters. Hash-only requests shrink headers and bodies, improving TLS record efficiency and stream concurrency under HTTP/2/3.
- Keep response compression on (Brotli for HTTPS). Persisted queries are complementary, not a replacement.
Batching and persisted queries
- If you batch operations, persisted queries still help. Cacheability gets trickier because a batch may include non-cacheable operations. Consider: separate batch endpoints (cacheable vs not), or prohibit mixing mutations with queries in the same batch.
- For edge caching, single-operation GETs are simpler and more predictable.
Server-side implementation tips
- Bind the persisted id to the exact operation text (and optionally operationName). Reject requests where the supplied id doesn’t match the stored hash.
- Cache the parsed/validated document and execution plan keyed by id to skip repeated work.
- Enforce a registry: in production, disable execution of arbitrary text. Only persisted operations should run. This blocks expensive or unexpected queries and simplifies WAF allowlisting.
- Cap result sizes and execution complexity even for persisted operations. Persisted ≠ safe from abuse.
Sample server logic (pseudocode)
const registry = new Map(); // id -> { query, operationName }
app.use('/graphql', async (req, res) => {
const { extensions, query, variables, operationName } = readPayload(req);
const persisted = extensions?.persistedQuery?.sha256Hash;
if (persisted) {
const entry = registry.get(persisted);
if (!entry) {
if (query) {
// APQ seed path: verify hash then store
if (sha256(query) !== persisted) return res.status(400).json({ error: 'Hash mismatch' });
registry.set(persisted, { query, operationName });
} else {
return res.status(200).json({ errors: [{ message: 'PersistedQueryNotFound' }] });
}
}
const { query: persistedQuery } = registry.get(persisted);
return executeAndRespond(persistedQuery, variables, { operationName });
}
// Optional: In prod, block arbitrary text
if (process.env.BLOCK_ARBITRARY === 'true') return res.status(400).json({ error: 'Only persisted queries allowed' });
return executeAndRespond(query, variables, { operationName });
});
Versioning and invalidation
- Include a version field in the manifest (e.g., v=2026-07-01) and append it to the extensions or as a dedicated query param. This provides a clean cache-busting switch after schema changes.
- Invalidate selectively when a resolver’s semantics change. Keep TTLs modest (30–120s) for data that updates regularly and pair with stale-while-revalidate to soften cache misses.
- For pre-registered manifests, treat uploads as part of your CI/CD and keep an audit trail linking commit SHAs to operation ids.
Observability and SLOs
- Log by persisted id and operationName. Maintain a dictionary so dashboards can display human-friendly names.
- Track hit rate at three layers: CDN cache hit ratio (CHR), server plan cache hit rate, and APQ seed rate (misses triggering retries). Aim to drive APQ misses to near-zero in production.
- Alert on unusual growth in distinct ids, which can indicate unbounded client variants or build regressions.
Security considerations
- Whitelist-only execution: In production, disallow arbitrary operations. Persisted registry becomes your allowlist.
- WAF simplicity: You can allow only GET /graphql with the persistedQuery param for public data paths.
- Hash collision risk is negligible with SHA-256 but still verify on APQ seed: the provided text must hash to the same value.
- Mutations should not be cached at the CDN. Prefer POST for mutations and set Cache-Control: no-store.
- Consider request signing (HMAC) for high-value public endpoints to prevent hotlinking of your cached responses.
Example CDN edge rules (conceptual)
# Cache only GETs with a persistedQuery hash
map $arg_extensions $has_persisted {
default 0;
~"persistedQuery" 1;
}
# Bypass cache if Authorization is present
map $http_authorization $has_auth { default 0; ~.+ 1; }
proxy_cache_key "$scheme$request_method$host$request_uri";
location = /graphql {
if ($request_method = GET) {
if ($has_persisted = 1) {
if ($has_auth = 0) {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=30";
proxy_cache graphql_cache;
}
}
}
proxy_pass http://origin;
}
Expected gains (rules of thumb)
Real-world results vary, but teams commonly report:
- 20–60% reduction in upstream bandwidth by removing query text and enabling edge caching for popular reads.
- 15–40% faster P95 TTFB for cacheable views once CDN caching is turned on.
- 30–80% drop in GraphQL parsing/validation CPU when the server reuses parsed plans for persisted operations.
Even if your data is only moderately cacheable, the CPU savings and observability improvements usually justify persisted queries on their own.
Migration strategy checklist
- Start with APQ in staging to identify high-traffic operations and validate hashing/normalization.
- Move hot, public queries to pre-registered persisted queries and GET endpoints.
- Introduce a version param and short TTLs with stale-while-revalidate.
- Disable ad-hoc text execution in production once coverage is high.
- Instrument hit rates, origin offload, and TTFB before/after to quantify wins.
- Educate frontend teams about variable canonicalization and avoiding unbounded query variance.
Common pitfalls to avoid
- Forgetting canonical variable ordering, causing surprising cache misses.
- Mixing mutations into batch requests that you intended to cache.
- Leaving Authorization headers on public GETs, which suppresses CDN caching.
- Not pinning operationName in the registry, allowing ambiguous documents.
- Skipping complexity limits because “it’s persisted.” Keep guardrails.
FAQs
- Can I persist mutations? Yes, but never cache them at the edge and prefer POST. Persisted ≠ cacheable.
- Do persisted queries help with subscriptions? Not meaningfully; subscriptions are long-lived and not cacheable by CDNs in the same way.
- What if my schema changes? Use a version flag and roll out a new manifest; keep old entries during the transition.
- Do I still need compression? Yes. Persisted queries reduce request size; compression still helps response size.
Conclusion
Persisted queries turn GraphQL from a highly dynamic interface into a predictable, cache-friendly, and secure API surface. Combining pre-registered ids, GET for cacheable reads, canonical variables, and tight observability yields substantial gains in bandwidth, TTFB, and server CPU. With a disciplined rollout and a modest amount of tooling, you can turn your GraphQL layer into a fast, edge-optimized delivery path for your most important user journeys.
Related Posts
GraphQL Caching Strategies on Client and Server: A Practical Playbook
A practical guide to GraphQL caching on the client and server—policies, headers, CDNs, and invalidation patterns that keep data fast and correct.
GraphQL Schema Stitching and Type Merging: A Practical Guide
A practical guide to GraphQL schema stitching and type merging with patterns, code, performance tips, and pitfalls for building a reliable gateway.
Designing REST APIs with Partial Response Field Selection (fields, select, $select)
Design and implement REST API partial responses for speed and safety—syntax options, caching, security, and implementation patterns with examples.