Persisted Queries
Persisted operations: pre-register queries on the server; clients send only an ID. Smaller payloads, allowlist security, better caching.
GraphQL — persisted queries
EXAMPLE
// ===== Problem with arbitrary queries =====
// - Public APIs: anyone can craft expensive queries (DoS)
// - Network: queries are large (KBs); ID is tiny
// - Caching: query content varies across clients
// ===== Solution: persisted operations =====
// 1. Extract all client queries at build time
// 2. Register them on the server (or load from a file)
// 3. Client sends only the HASH / ID
// 4. Server looks up + executes
// 5. Optionally REJECT unknown queries (allowlist)
// ===== Build-time extraction =====
// With Apollo Client + persistgraphql-cli:
npm install -D @graphql-codegen/cli @graphql-codegen/client-preset
// codegen.yml
schema: ./schema.graphql
documents: 'src/**/*.{ts,tsx,gql}'
generates:
./src/generated/persisted-operations.json:
plugins:
- 'persisted-operations'
// Output:
{
"abcd1234": "query GetUser($id: ID!) { user(id: $id) { name } }",
"ef56...": "..."
}
// ===== Apollo Client =====
import { ApolloClient, HttpLink } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = createPersistedQueryLink({ sha256 }).concat(new HttpLink({ uri: '/graphql' }));
const client = new ApolloClient({ link, cache });
// Client sends:
// POST /graphql { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc..." } } }
// If server doesn't recognise, returns PersistedQueryNotFound; client falls back to sending full query once.
// ===== Server side (Apollo Server) =====
import { ApolloServer, BaseContext } from '@apollo/server';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: { cache: new InMemoryLRUCache({ maxSize: 10_000 }) },
});
// Strict mode (allowlist; reject unknown queries):
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const knownQueries = require('./persisted-operations.json');
// Custom plugin:
plugins: [
{
async requestDidStart({ request }) {
const id = request.extensions?.persistedQuery?.sha256Hash;
if (!id || !knownQueries[id]) {
throw new Error('Unknown persisted query');
}
request.query = knownQueries[id];
},
},
],
// ===== Yoga =====
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations';
const yoga = createYoga({
schema,
plugins: [
usePersistedOperations({
getPersistedOperation: (key) => knownQueries[key],
}),
],
});
// ===== Benefits =====
// - Smaller request payloads
// - Allowlist mode prevents arbitrary queries from clients
// - Better edge cache (queries identified by hash)
// - Easier query analysis (server knows ALL queries upfront)
// ===== Trade-offs =====
// - Build-time tooling required
// - Schema changes need rebuild + redeploy
// - Harder ad-hoc debugging (no /graphql sandbox in prod)
// ===== Patterns =====
// - Use Automatic Persisted Queries (APQ) for internal / mobile
// - Use STRICT MODE allowlist for public APIs
// - Keep manifest in source control or fetch from CDN
// - Pair with CDN edge caching for read-heavy queries
// ===== Pitfalls =====
// - Forgetting to update the manifest on schema change
// - GET requests with persisted IDs (queryable in browser cache)
// - Mixing arbitrary queries + strict mode without a clear gate
Why it matters
Persisted queries replace large query payloads with a tiny ID. APQ for convenience, strict allowlist for public APIs. Smaller payloads, edge-cache friendly, and a hard guard against arbitrary queries. Build-time codegen + cache-backed server lookup.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Send a hash instead of the full query string. Server resolves the hash.Try it Yourself »
Discussion
Loading…