Implementing Automatic Persisted Queries in Federation

Automatic persisted queries (APQ) let clients send a short SHA-256 hash of an operation instead of the full query string, shrinking request payloads and — critically for federated graphs — making read operations cacheable as GET requests at the CDN edge. This guide walks the APQ handshake, the Apollo Router configuration, the Apollo Client link setup, and the safelisting path, complementing the layered view in Caching Strategies for Federated GraphQL.

When to Use This Pattern

  • You want CDN/edge caching for read operations, which requires sending them as GETs — only practical when the query is replaced by a short hash.
  • Your clients send large operations over constrained networks and the repeated multi-kilobyte query bodies are wasting bandwidth.
  • You are moving toward an operation safelist (only registered operations allowed) and want the hash-based registry as the foundation.

Prerequisites

The APQ Handshake

APQ is an optimistic two-phase protocol. The client first gambles that the router already knows the operation and sends only the hash; if the router has never seen it, the client retries with the full query plus the hash so the router can register it. Every subsequent client sends just the hash.

APQ handshake sequence A client sends a query hash to the router; on PersistedQueryNotFound it retries with the full query and hash, which the router stores; later requests send only the hash and hit cache. Client Apollo Router 1. GET ?extensions={sha256Hash} 2. PersistedQueryNotFound 3. POST full query + hash stores hash → query 4. query result 5. later: GET hash only — known, cacheable 6. result (served from edge/response cache)

The hash is a SHA-256 of the exact operation string. Step 2’s PersistedQueryNotFound error is the normal, expected first-contact signal — not a failure — and the client library handles the retry automatically. Once registered, the operation is reachable by hash alone, and because a hash-only read can be issued as a GET, it becomes cacheable by any HTTP cache between the client and the router.

Router APQ Configuration

# router.yaml — automatic persisted queries
apq:
  enabled: true
  router:
    cache:
      # In-memory cache of recently seen hash→query mappings on each replica
      in_memory:
        limit: 2048
      # Shared Redis store so a hash registered on one replica is known to all
      redis:
        urls:
          - "redis://apq-store.internal:6379"
        timeout: 5ms
        ttl: 86400s            # keep registered operations for a day

# Allow read operations to arrive as GET so they are edge-cacheable.
# Mutations stay POST regardless.
supergraph:
  introspection: false

The Redis store matters in a multi-replica deployment: without it, an operation registered against replica A returns PersistedQueryNotFound when the next request lands on replica B, forcing a redundant full-query round trip. The in-memory tier in front of Redis keeps the hot operation set local.

Apollo Client Setup

On the client, the persisted-query link computes the hash and manages the retry. Chain it ahead of the terminating HTTP link, and enable GET for hashed reads so the CDN can cache them.

import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

// Computes the SHA-256 hash and runs the APQ handshake/retry automatically
const persistedQueryLink = createPersistedQueryLink({
  sha256,
  useGETForHashedQueries: true, // hashed reads go out as GET → edge-cacheable
});

const httpLink = new HttpLink({ uri: 'https://router.internal/graphql' });

export const client = new ApolloClient({
  // persistedQueryLink MUST come before the terminating httpLink
  link: from([persistedQueryLink, httpLink]),
  cache: new InMemoryCache(),
});

useGETForHashedQueries: true is the switch that unlocks edge caching: the initial registration POST stays a POST, but every subsequent hash-only read is a GET that a CDN can serve. Mutations are never sent as GET.

Safelisting and the Operation Registry

Plain APQ registers any operation a client presents — convenient, but it does not restrict which operations the graph will execute. For locked-down production graphs, move from APQ to a safelist (also called persisted query lists or an operation registry): the client build emits a manifest of approved operation hashes, you publish it, and the router rejects any hash not on the list. This eliminates arbitrary ad-hoc queries from untrusted clients while keeping the bandwidth and caching benefits.

# Generate a persisted-query manifest at client build time, then publish it
# so the router will only honour these operation hashes.
npx generate-persisted-query-manifest --config ./pq-manifest.config.json
rover persisted-queries publish my-graph@prod \
  --manifest ./persisted-query-manifest.json

With a published list, the router runs in safelist mode and the optimistic “register on miss” behaviour is disabled — an unknown hash is denied rather than registered. This is the recommended posture for public-facing graphs.

Verification Steps

# 1. First contact — hash only, expect PersistedQueryNotFound (this is normal)
HASH=$(printf '{ products { id name } }' | sha256sum | cut -d' ' -f1)
curl -s "https://router.internal/graphql?extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22$HASH%22%7D%7D"
# Expect: {"errors":[{"message":"PersistedQueryNotFound", ...}]}
# 2. Register: POST the full query plus the hash
curl -s -X POST https://router.internal/graphql \
  -H 'content-type: application/json' \
  -d "{\"query\":\"{ products { id name } }\",\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"$HASH\"}}}"
# Expect: a normal data response — the router has now stored the mapping

# 3. Replay hash-only as a GET — now known, and cacheable at the edge
curl -sD - "https://router.internal/graphql?extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22$HASH%22%7D%7D" | grep -i cache-control

A correct setup returns PersistedQueryNotFound exactly once per new operation, then serves the hash thereafter, with public reads carrying a cacheable Cache-Control header.

Common Mistakes and Gotchas

  • Treating PersistedQueryNotFound as an error. It is the expected first-contact signal that triggers registration. Alerting on it produces constant false alarms on deploys that introduce new operations.
  • No shared APQ store across replicas. Without Redis, each replica registers operations independently, so load-balanced clients keep hitting PersistedQueryNotFound and retrying with full bodies, erasing the bandwidth win.
  • Hash mismatch from query reformatting. The hash is over the exact operation string; if a client pretty-prints or reorders the query differently from what was registered, the hash changes and registration repeats. Hash the canonical string the client actually sends.
  • Expecting APQ to authorise operations. Plain APQ is not a safelist — it registers whatever it sees. Use a published persisted-query manifest for access control.

Frequently Asked Questions

Is PersistedQueryNotFound a failure I need to fix?

No. It is the deliberate first step of the handshake: the client optimistically sends the hash, the router has not seen it, and this error tells the client to retry with the full query so the router can register it. It happens once per new operation and the client library handles the retry transparently.

How does APQ actually improve caching?

Full GraphQL operations are normally sent as POSTs, and standard HTTP caches do not cache POSTs. APQ replaces the query body with a short hash that can be sent as a GET, which CDNs and proxies will cache, with the hash as the cache key. Pair it with the router response cache for the full benefit described in Entity Caching with the Apollo Router Response Cache.

What is the difference between APQ and a persisted query safelist?

APQ registers any operation a client presents and is purely an optimisation. A safelist (persisted query manifest) is published from your build and the router rejects any operation not on it, so it doubles as access control. Safelisting builds on the same hash mechanism but disables the optimistic “register on miss” behaviour.