---
title: "GraphQL APQ: Cut Mobile API Latency with Edge Caching"
published: true
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: mobile, api, architecture, performance
canonical_url: https://mvpfactory.co/blog/graphql-apq-edge-caching
---
What We Are Building
By the end of this workshop, you will have a working APQ pipeline: a KMP client that negotiates SHA-256 hashes automatically, a server-side allowlist that enforces query registration, and a Cache-Control header strategy that turns GET-based GraphQL requests into CDN-cacheable assets. Teams report 40–65% latency reductions on cache-warm requests. Let me show you why — and how to replicate it.
Prerequisites
- Apollo Kotlin (KMP) in your shared module
- Apollo Server (Node.js) on the backend
- A CDN (Fastly, Cloudflare, or CloudFront) in front of your GraphQL endpoint
- Basic familiarity with GraphQL queries and HTTP caching headers
The Wire Cost Problem
Every standard GraphQL request ships the full query string as a POST body. On mobile clients on degraded networks, a 2–4 KB query string on every call is a latency budget killer. POST bodies bypass edge caches entirely. Your CDN is invisible. Every request hits origin.
| Request Type | Cacheable at Edge | Avg Payload | 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%+ |
APQ closes that gap.
Step 1 — Enable APQ on the KMP Client
Here is the minimal setup to get this working:
// 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()
One line. Zero behavior regression on cold start. The shared module runs identically on Android and iOS — one implementation, zero platform divergence. Apps that need consistently snappy UI responses (I have HealthyDesk on my work machine for desk exercise reminders — exactly the kind of lightweight mobile client that benefits immediately from warm-path gains) will see the difference right away.
Step 2 — Understand the Two-Phase Negotiation
Cold start (cache miss):
# Request 1: client sends hash only
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123..."}}
→ HTTP 200: { "errors": [{ "message": "PersistedQueryNotFound" }] }
# Request 2: client retries with full query — server registers hash, executes
POST /graphql { "query": "query GetProduct($id: ID!) { ... }", "extensions": { "persistedQuery": ... } }
→ HTTP 200: { "data": { ... } }
Warm path (all subsequent requests):
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123..."}}&variables={"id":"42"}
→ HTTP 200 (from CDN edge — origin never involved)
The cold-start overhead is a one-time cost per hash per server restart cycle. From Phase 2 onward, your CDN treats the request exactly like a static asset.
Step 3 — Enforce an Allowlist on the Server
Without an allowlist, any client can register and execute arbitrary queries. APQ becomes a query introspection vector rather than a security control. This is a hard security boundary.
const allowlist = new Map<string, DocumentNode>();
// Pre-populate at deploy time from your compiled 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');
}
},
}),
},
],
});
Generate the allowlist at build time from your client's compiled query manifest. Ship new client → regenerate manifest → deploy registry before rollout. No hash, no execution. You also get a natural audit trail of every query permitted in production.
Step 4 — Set Cache-Control Headers
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
Here is the gotcha that will save you hours — CDN vendor syntax for cache tag invalidation is not interchangeable:
# Fastly / Varnish
Surrogate-Key: graphql product-listing
# Cloudflare
Cache-Tag: graphql product-listing
# AWS CloudFront — requires custom origin response policy mapping
Validate purge semantics against your specific vendor before assuming they work as expected.
Gotchas
Authenticated queries are a data leakage risk. Caching personalized responses at a public CDN edge is dangerous. Use Cache-Control: private or Vary: Authorization for authenticated routes. APQ's wire-cost benefit on those routes reduces to payload compression only.
In-memory hash registries compound cold starts. If your registry lives in process memory rather than a shared store like Redis, every new instance restarts the cold-start cycle. On high instance-count deployments this compounds fast.
Low-traffic routes recur on cold start. Background sync and admin operations fire infrequently. Across distributed server restarts, the two-request overhead repeats. APQ delivers the most value on high-traffic, cache-warm routes.
SHA-256 collisions are negligible in practice, but the allowlist provides an implicit safety net — a colliding hash maps to a registered document, not an attacker's query.
Conclusion
Let me show you a pattern I use in every project: enable APQ first, before touching resolvers. It is a one-line client change. Generate your allowlist at build time, tied to CI/CD so server and client manifests stay in sync. Then 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. 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.
Apollo Kotlin APQ docs: apollographql.com/docs/kotlin
Top comments (0)