GraphQL Persisted Queries
Persisted queries store pre-approved query documents on the server. Instead of sending the full query text in every HTTP request, the client sends only a short hash. The server looks up the full query by hash and executes it. This reduces bandwidth, enables CDN caching, and prevents clients from running arbitrary queries in production.
The Normal Query Request
Normal GraphQL request — query text travels every time:
────────────────────────────────────────────────────────
POST /graphql
Body: {
"query": "query GetProduct($id: ID!) { product(id: $id) { name price description imageUrl category { name } } }",
"variables": { "id": "p42" }
}
Problems:
- Long query strings waste bandwidth on every request
- Mobile clients pay more for large payloads
- Anyone can send any query to your server
The Persisted Query Request
Persisted query request — only the hash travels:
──────────────────────────────────────────────────
GET /graphql?operationName=GetProduct
&variables={"id":"p42"}
&extensions={"persistedQuery":{"version":1,
"sha256Hash":"b94f6f1..."}}
Benefits:
- Tiny request (hash vs full query text)
- GET request → CDN can cache it
- Server rejects hashes not in the allow-list
How Persisted Queries Work
First request (hash unknown to server):
─────────────────────────────────────────
Client ──► Server: { hash: "abc123" }
Server ──► Client: { errors: [{ code: "PERSISTED_QUERY_NOT_FOUND" }] }
Client ──► Server: { hash: "abc123", query: "full query text" }
Server: stores hash → query, executes, responds
Client ──► Server: { hash: "abc123" } ← next time, no query text needed
Server: looks up "abc123" → executes from cache
Visual:
────────
Client Server
────── ──────
Send hash only ──────────────► Hash found? Yes → execute
Hash found? No ↓
Send hash + query ────────────► Store hash→query, execute
Send hash only ──────────────► Hash found? Yes → execute (always)
Automatic Persisted Queries with Apollo Client
npm install @apollo/client
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const persistedLink = createPersistedQueryLink({ sha256 });
const httpLink = createHttpLink({ uri: '/graphql' });
const client = new ApolloClient({
link: persistedLink.concat(httpLink), ← Persisted queries first
cache: new InMemoryCache(),
});
// From this point, Apollo Client handles hashing and fallback automatically.
// No changes needed in your useQuery / useMutation hooks.
Allow-List Mode – Maximum Security
In allow-list mode, the server only accepts registered query hashes. Any query not registered during the build process is rejected, even if the client sends the full query text. This prevents attackers from running custom queries against your production API.
Build step:
────────────
1. Run your app's queries through the extractor tool
2. Generate a manifest: { "abc123": "query GetProduct...", ... }
3. Upload manifest to server (or bundle with server deployment)
Runtime:
─────────
Client sends hash "abc123" → Server checks allow-list → ✓ execute
Attacker sends hash "xyz789" → Server checks allow-list → ✗ reject
Attacker sends full query text → ✗ reject (allow-list mode ignores query text)
Persisted Queries with Apollo Router (Federation)
Apollo Router configuration (router.yaml):
────────────────────────────────────────────
apq:
enabled: true ← Automatic persisted queries on by default
persisted_queries:
enabled: true
safelist:
enabled: true ← Reject unregistered queries
require_id: true ← Must provide a registered hash
Key Points
- Persisted queries replace full query text with a short hash, reducing request size significantly.
- GET requests carrying a hash can be cached by CDNs, unlike POST requests with full query bodies.
- Apollo Client handles the hash generation, first-request fallback, and retry automatically.
- Allow-list mode locks the API to only pre-registered queries, eliminating arbitrary query execution in production.
- The server stores hash-to-query mappings and resolves them at request time with near-zero overhead.
