Why Persisted Queries Are a Practical Win for GraphQL APIs
Persisted queries replace full GraphQL strings with tiny hashes, cutting payload size and locking down the API to a pre‑approved whitelist. This post shows a minimal Apollo Server + Client setup, the operational trade‑offs, and a verification checklist you can run today.
28 Oct 2025, 05:00 UTC

The problem: bloated requests and an open attack surface
Every GraphQL request ships the full query string, variables, and operation name. On mobile networks that payload can be several kilobytes, and the server must parse and validate arbitrary strings on every call. That openness also invites malicious queries that probe schema depth, cause denial‑of‑service, or exfiltrate data through crafted selections.
Thesis: Persisted queries turn the query into a tiny, pre‑validated identifier
By registering each query once and thereafter sending only a deterministic hash (or an opaque ID), you cut request size dramatically and give the server a whitelist of allowed operations. The change is purely operational—no schema redesign required—and most major GraphQL servers support it out of the box.
How it works in practice
Server side (Apollo Server 4.x)
Enable the persistedQueries plugin and point it at a store that survives restarts (Redis, in‑memory map for demos, or a database for production).
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { persistedQueries } from '@apollo/server/plugin/persistedQueries';
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
persistedQueries({
// The plugin expects a Map‑like get/set interface
cache: {
get: (key) => redis.get(key),
set: (key, value) => redis.set(key, value),
},
// Reject any request that does not carry a known hash
forbidUnknownQueries: true,
}),
],
});
await startStandaloneServer(server, { listen: { port: 4000 } });
The plugin registers a query the first time it sees the full string (client sends extensions: { persistedQuery: { version: 1, sha256Hash: "" } } without the hash). Subsequent requests can omit the query text and send only the hash.
Client side (Apollo Client 3.x)
Apollo Client automatically hashes each document and falls back to the full query on the first request.
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
const httpLink = new HttpLink({ uri: 'https://api.example.com/graphql' });
const client = new ApolloClient({
link: createPersistedQueryLink({ useGETForHashedQueries: true }).concat(httpLink),
cache: new InMemoryCache(),
});
With useGETForHashedQueries the client issues a tiny GET request (?extensions={"persistedQuery":{"version":1,"sha256Hash":""}}) for cached queries, further reducing overhead.
Trade‑offs you should weigh
- Deployment coupling – The hash‑to‑query map must be present on every server instance. A rolling deploy that clears the store will cause 400 errors until the map is repopulated.
- Cache‑invalidation strategy – When you change a query, its hash changes. You need a process (CI step, script, or admin UI) to re‑register the new hash before the new client code ships.
- Memory footprint – Very large query sets (thousands of unique operations) can bloat an in‑memory store. Off‑load to Redis or a key‑value DB for production.
- Bypass risk – If
forbidUnknownQueriesis false (default in some versions), a malicious client can still send arbitrary queries. Always enable the flag in production.
Verification checklist (run once per environment)
- Start the server with the plugin enabled and
forbidUnknownQueries: true. - Send a known query from the client; confirm the server logs a
persistedQueryRegisterevent (or check Redis for the new key). - Resend the same operation; the request payload should contain only the
extensions.persistedQuery.sha256Hashfield. Measure the byte count – expect a 70‑90 % reduction versus the full query. - Attempt a request with a fabricated hash or a full query string not in the store; the server must return HTTP 400 with an error like
Persisted query not found. - Restart the server (or simulate a rolling deploy) and repeat step 3 to ensure the map survived or was re‑hydrated.
Actionable next step
Add the persisted‑query plugin to your staging Apollo Server, point it at a shared Redis instance, and enable forbidUnknownQueries. Run the verification checklist above. If all checks pass, promote the same configuration to production and integrate the client‑side createPersistedQueryLink into your build pipeline. You’ll immediately see smaller request payloads on mobile and a concrete reduction in the attack surface without touching your schema.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.