How Facebook’s Persisted Queries Tame GraphQL Overhead at Scale
Persisted queries replace full GraphQL strings with short hashes, reducing bandwidth and enabling safe allow‑list enforcement in large‑scale APIs.
18 Sept 2025, 05:16 UTC

The problem: payload size and unsafe queries
When Facebook moved its mobile apps from REST to GraphQL, engineers noticed two recurring costs. First, a single GraphQL request could contain a large query string, wasting precious bandwidth on 3G/4G links. Second, because any client could send an arbitrary query, the gateway had to parse, validate, and potentially execute expensive, deeply nested selections on every call, opening the door to resource‑exhaustion attacks.
The team needed a way to keep the flexibility of GraphQL while limiting the amount of data sent over the wire and ensuring that only known, safe queries could reach the backend.
The solution: persisted queries with an allow‑list
Persisted queries shift the query text from the request to a build‑time step. During development, each unique GraphQL document is hashed (commonly using SHA‑256) and the hash is stored in an allow‑list on the server. At runtime, the client sends only the hash (and optionally the operation name) instead of the full query string. The gateway looks up the hash; if it is present in the allow‑list, the associated query is executed; otherwise the request is rejected.
This approach gives three concrete benefits:
- Bandwidth savings: a typical query hash is 32 bytes (hex‑encoded) versus hundreds of bytes for the full string.
- Security: unknown or malicious queries cannot be executed because they are not in the allow‑list.
- Cache‑friendliness: the same hash always maps to the same response, making HTTP‑level caching (e.g., at the edge) straightforward.
Worked example: setting up persisted queries with Apollo Server
The following snippet shows how a team could enable persisted queries in a Node.js GraphQL gateway using the open‑source apollo-server-plugin-persisted-queries package. The example is illustrative; it does not claim to have been run in production.
// server.js
const { ApolloServer } = require('apollo-server');
const { PersistedQueryPlugin } = require('apollo-server-plugin-persisted-queries');
const fs = require('fs');
// Load the allow‑list generated at build time (e.g., via graphql-codegen)
const persistedQueries = JSON.parse(fs.readFileSync('./persisted-queries.json', 'utf8'));
const server = new ApolloServer({
typeDefs: /* your GraphQL schema */,
resolvers: /* your resolver map */,
plugins: [PersistedQueryPlugin({ persistedQueries })],
});
server.listen({ port: 4000 }).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
The persisted-queries.json file is a simple map:
{
"sha256:9a1b2c3d4e5f6...": "query GetUser($id: ID!) { user(id: $id) { id name email } }",
"sha256:ff11223344556...": "mutation UpdatePost($id: ID!, $title: String!) { updatePost(id: $id, title: $title) { id title } }"
}
From the client side (using Apollo Client), a request would look like:
fetch('https://api.example.com/graphql', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
extensions: {
persistedQuery: {
version: 1,
sha256Hash: '9a1b2c3d4e5f6...'
}
},
variables: { id: '123' }
})
})
.then(r => r.json())
.then(console.log);
If the hash matches an entry in the allow‑list, the server resolves the query and returns the usual JSON payload. If the hash is missing, the server responds with a 400 error and a message like "Persisted query not found".
Trade‑offs and limitations
While persisted queries cut bandwidth and improve safety, they introduce operational overhead:
- Build step required: every UI change that alters a GraphQL document must regenerate the allow‑list and redeploy the gateway.
- No ad‑hoc queries: debugging or exploratory requests from tools like GraphQL Playground must be sent as ordinary POST requests with the full query string, or added temporarily to the allow‑list.
- Cache‑validation complexity: edge caches must key on the hash; if the allow‑list is updated, cached responses for older hashes may become stale until they expire.
Teams mitigate these costs by automating the allow‑list generation in CI pipelines and by using short TTLs (e.g., 60 seconds) for edge‑cached persisted‑query responses, allowing rapid rollout of new queries.
Practical verification
To confirm that persisted queries are working in a staging environment, you can:
- Run a GraphQL query from a client configured to use persisted queries.
- Inspect the network request; you should see a GET with
extensions.persistedQuerycontaining a hash and noqueryparameter. - Check the server logs (or Apollo Server tracing) for a "Persisted query hit" message.
- Attempt to send a request with a hash that is not in the allow‑list; the server should return a 400 error.
Observing these behaviors gives confidence that the allow‑list is being enforced and that bandwidth savings are being realized.
Actionable takeaway
If your GraphQL gateway serves many clients and you notice large request payloads or want to lock down which queries can be executed, consider adopting persisted queries. Start by generating a hash‑based allow‑list in your build system, plug a persisted‑query plugin into your gateway, and configure your Apollo Client (or equivalent) to send the hash via the extensions field. Monitor network traffic and server logs to verify hits and misses, and iterate on the allow‑list update cadence to balance agility with safety.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.