Persisted Queries are not about making GraphQL faster in terms of raw execution speed for a single query; they’re about reducing the network overhead and server-side parsing/validation costs that plague high-volume GraphQL APIs.
Let’s watch a typical GraphQL request unfold when Persisted Queries aren’t enabled, then see how it changes.
Without Persisted Queries:
Imagine a mobile app fetching user data.
- Client: Constructs the GraphQL query string:
And the variables:query GetUser($userId: ID!) { user(id: $userId) { id name email } }{ "userId": "user-123" } - Client: Sends a POST request to the GraphQL endpoint (e.g.,
/graphql). The request body is JSON containingqueryandvariables:POST /graphql Content-Type: application/json { "query": "query GetUser($userId: ID!) { user(id: $userId) { id name email } }", "variables": { "userId": "user-123" } } - Server: Receives the request.
- Parses: The raw query string into an Abstract Syntax Tree (AST).
- Validates: The AST against the GraphQL schema to ensure it’s a valid request.
- Executes: The validated AST, fetching data from your backend services.
- Server: Responds with the JSON data.
The Problem: Every single request, even for the exact same query, sends the entire query string over the network. For mobile apps, or networks with high latency, this is a lot of repeated data. For the server, it’s repeated parsing and validation work.
With Persisted Queries:
The core idea is to send the query string once to register it, and then subsequent requests only send a short identifier for that registered query.
-
Client (Initial Registration):
- The client (or a build process) sends the query string and the server hashes it. This hash becomes the unique identifier. Common hashing algorithms are SHA-256.
- The client sends a special request to register the query. The exact endpoint and format depend on the GraphQL server implementation (e.g., Apollo Server, Hasura, etc.).
- Example (conceptual, using Apollo Server’s
persisted-queriesextension):POST /graphql Content-Type: application/json { "query": "query GetUser($userId: ID!) { user(id: $userId) { id name email } }", "operationName": "GetUser" // Often useful for clarity } - Server: Receives the query, calculates its hash (e.g.,
a1b2c3d4e5f6...), and stores this mapping:hash -> query string. This mapping is often stored in memory (for speed) or a persistent store (like Redis or a database). The server responds with the hash.
-
Client (Subsequent Requests):
- Now, for every request using this query, the client only sends the hash and the variables.
- Example:
POST /graphql Content-Type: application/json { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "a1b2c3d4e5f6..." // The hash calculated in step 1 } }, "variables": { "userId": "user-123" } }
-
Server:
- Receives the request.
- Extracts the
sha256Hashfrom theextensionsfield. - Looks up the query string from its stored mapping using the hash.
- If the hash is found:
- Parses: The retrieved query string.
- Validates: The parsed query against the schema.
- Executes: The query.
- If the hash is not found (e.g., the client is using an old hash, or the query was deleted): The server responds with an error, typically
PersistedQueryNotFound(HTTP 422), instructing the client to send the full query string again. This is the fallback mechanism.
The Benefits:
- Reduced Network Payload: Instead of sending
query: "..."(which can be hundreds of characters), you sendsha256Hash: "..."(typically 64 hex characters). This is a massive win for mobile and high-latency environments. - Reduced Server Load: The server doesn’t need to parse and validate the same query string repeatedly. It does it once on registration and then just looks up the pre-parsed/validated form or re-parses/validates the retrieved string. Many implementations cache the parsed AST.
- Query Denylist/Allowlist: You can control exactly which queries are allowed to run by only storing and registering known, safe queries.
Configuration and Implementation Details:
This is typically handled by a GraphQL server extension or middleware. For Apollo Server, it’s the ApolloServerPlugin persistedQueries.
Key Configuration Points:
-
Storage: Where are the
hash -> querymappings stored?- In-memory: Fastest, but lost on server restart. Good for stateless servers.
- Redis: Common choice for persistence and fast lookups.
- Database: Slower, but robust.
Example for Apollo Server with Redis:
import { ApolloServer } from '@apollo/server'; import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/prod'; import { Redis } from 'ioredis'; import { usePersistedQueries } from '@apollo/server/plugin/persistedQueries'; const redisClient = new Redis({ host: 'localhost', // Your Redis host port: 6379, // Your Redis port }); const server = new ApolloServer({ typeDefs, resolvers, plugins: [ ApolloServerPluginLandingPageLocalDefault(), usePersistedQueries({ cache: redisClient, // Use your Redis client // Optional: TTL for cache entries ttl: 60 * 60 * 24, // 24 hours // Optional: Custom hash function (default is SHA-256) // hashAlgorithm: 'sha256', }), ], }); -
Hash Algorithm: SHA-256 is standard. Ensure your clients and server agree on the algorithm.
-
Client-side Support: Your GraphQL client library (Apollo Client, Relay, etc.) needs to support Persisted Queries. This usually involves configuring the client to use the
persistedQueryextension and to handle thePersistedQueryNotFounderror by retrying with the full query.Example with Apollo Client v3:
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'; import { setPersistedQueries } from '@apollo/client/link/persisted-queries'; const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' }); const link = setPersistedQueries(httpLink); // This is the key part const client = new ApolloClient({ link: link, cache: new InMemoryCache(), });
The "One Thing" Most People Don’t Know:
Persisted Queries don’t magically prevent a client from sending a query string. The server-side plugin is designed to fall back. When a client sends a persistedQuery hash and the server can’t find it (either because it was never registered, or the cache expired and it wasn’t re-registered), the server sends back a PersistedQueryNotFound error (usually with a 422 status code and a specific error code in the JSON response). A well-behaved client will then automatically retry the same request, but this time it will omit the persistedQuery extension and include the full query string. The server will then process that full query, execute it, and crucially, re-register the query and its hash in the cache for future requests. This ensures that even if a query temporarily disappears from the cache, it’s not lost and will be available again shortly.
The next step is often understanding how to manage and monitor your persisted queries, ensuring that frequently used queries are always available and that no unexpected or malicious queries slip through.