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.

Leave a Comment