GraphQL APQ: Cut mobile API latency with edge caching
Meta description: Learn how APQ with SHA-256 content-addressed requests and CDN edge caching can eliminate redundant payload transfer and cut mobile GraphQL latency by 40–65%.
Tags: graphql mobile api kmp architecture
TL;DR
Automatic Persisted Queries (APQ) transform dynamic GraphQL query strings into short SHA-256 hashes, enabling GET-based requests that edge CDNs cache like static assets. Combined with a server-side allowlist and disciplined cache-control headers, teams report 40–65% latency reductions on cache-warm requests — without schema changes. This covers end-to-end implementation, including KMP client integration.
The wire cost problem in mobile GraphQL
In my experience building production systems, GraphQL’s developer ergonomics mask a hard infrastructure truth: every request ships the full query string as a POST body. For mobile clients on degraded networks, a 2–4 KB query string on every call is not a rounding error — it is a latency budget killer.
Most teams tune resolvers and add DataLoaders while ignoring the wire cost of the query itself. POST bodies bypass edge caches entirely. Your CDN is invisible. Every request hits origin.
| Request Type | Cacheable at Edge | Avg Payload (query only) | Typical Cache Hit Ratio¹ |
|---|---|---|---|
| Standard POST GraphQL | No | 1.5–4 KB | 0% |
| APQ GET (hash only) | Yes | ~70 bytes | 60–85%+ |
| REST GET | Yes | 0 bytes | 60–90%+ |
¹ Cache hit ratio translates to latency reduction only when CDN response time is significantly less than origin response time — typically true when origin p99 > 200ms and CDN edge p99 < 30ms. Actual latency reduction depends on your traffic distribution and origin performance profile. Teams on high-origin-latency stacks report 40–65% end-to-end latency reductions on cache-warm requests.
APQ closes that gap.
How APQ works: SHA-256 as a cache key
The protocol is a two-phase negotiation. Understanding both phases matters for debugging production issues.
Phase 1 — Cold start (cache miss):
# Request 1: Client sends hash only
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123..."}}
# Server response: hash not registered
HTTP 200
{ "errors": [{ "message": "PersistedQueryNotFound" }] }
# Request 2: Client retries with full query string — server registers hash, executes
POST /graphql
{
"query": "query GetProduct($id: ID!) { product(id: $id) { name price } }",
"extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123..." } }
}
# Server stores abc123 → document, executes, returns data
HTTP 200
{ "data": { "product": { "name": "...", "price": "..." } } }
Phase 2 — Warm path (all subsequent requests):
# Hash is now registered. All future requests are hash-only GETs.
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123..."}}&variables={"id":"42"}
# CDN edge serves from cache — origin never involved
HTTP 200 (from edge)
{ "data": { "product": { "name": "...", "price": "..." } } }
From Phase 2 onward, the request is a GET with a stable, content-addressed URL. Your CDN treats it exactly like a static asset. The cold-start overhead — two requests instead of one — is a one-time cost per hash per server restart cycle.
// KMP shared client — Apollo Kotlin APQ setup
val apolloClient = ApolloClient.Builder()
.serverUrl("https://api.example.com/graphql")
.httpEngine(DefaultHttpEngine())
.autoPersistedQueries() // handles two-phase negotiation automatically
.build()
Apollo Kotlin manages the negotiation transparently. The KMP shared module runs identically on Android and iOS — one implementation, zero platform divergence.
The server side: allowlist enforcement
Without an allowlist, any client can register and execute arbitrary queries — APQ becomes a query introspection vector rather than a security control. In production, the server must only execute pre-registered queries. This is a hard security boundary.
// Node.js / Apollo Server allowlist enforcement
const allowlist = new Map<string, DocumentNode>();
// Pre-populate at deploy time from your query manifest
queryManifest.forEach(({ hash, document }) => {
allowlist.set(hash, parse(document));
});
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart: async () => ({
async didResolveOperation({ request }) {
const hash = request.extensions?.persistedQuery?.sha256Hash;
if (hash && !allowlist.has(hash)) {
throw new ForbiddenError('Query not in allowlist');
}
},
}),
},
],
});
The allowlist is generated at build time from your client’s compiled query manifest. Ship a new client version, regenerate the manifest, deploy to the registry before the client rolls out. No hash, no execution. This also gives you a natural audit trail of every query your clients are permitted to run.
Cache-Control headers: making GraphQL indistinguishable from static assets
GET-based APQ requests are cache-eligible, but you must be explicit. For public, non-personalized data:
Cache-Control: public, max-age=60, stale-while-revalidate=300
For CDN-level cache tag invalidation, vendor syntax differs — this is a common misconfiguration:
# Fastly / Varnish
Surrogate-Key: graphql product-listing
# Cloudflare
Cache-Tag: graphql product-listing
# AWS CloudFront — requires custom origin response policy mapping
The steady-state architecture: clients send hash-only GETs → CDN edge serves cached responses for known hashes → origin only sees cache misses and first-registration requests. On high-traffic routes, origin offload is substantial.
Limitations and tradeoffs
APQ is not universally appropriate. Know the failure modes before you commit.
Authenticated query caching. Caching personalized responses at a public CDN edge is a data leakage vector. Use Cache-Control: private or Vary: Authorization for authenticated queries, or bypass the cache entirely. APQ’s wire-cost benefit on those routes reduces to payload compression only.
CDN vendor-specific configuration. Surrogate-Key is a Fastly and Varnish construct. Cloudflare uses Cache-Tag. CloudFront requires custom header policies. Validate purge semantics against your specific vendor before assuming they work as expected.
Cold-start overhead on low-traffic queries. On routes that fire infrequently — background sync, admin operations — the two-request cold-start recurs across distributed server restarts. If your hash registry is in-memory rather than shared (Redis, etc.), cold-start overhead compounds in proportion to your instance count.
Hash collisions. SHA-256 collision probability is negligible for any practical query corpus, but allowlist validation provides an implicit safety net — a colliding hash maps to a registered document, not the attacker’s query.
Where to start
Enable APQ in your KMP Apollo client. It is a one-line change with zero behavior regression on cold start — the two-phase negotiation handles misses gracefully, and the warm-path gains are immediate.
Generate your allowlist at build time, not runtime. Tie hash registration to your CI/CD pipeline so server and client manifests are always in sync. You get a security boundary and an auditable record of every query permitted in production.
Before touching resolvers, audit your Cache-Control headers. If your GraphQL responses are not carrying explicit caching headers on GET requests, your CDN is idle. Fix the HTTP layer first — no schema changes required, and the latency gains on cache-warm traffic are immediate and measurable.
The infrastructure to cache GraphQL at the edge already exists. APQ is what makes it work.