DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

GraphQL Persisted Queries with Edge Caching: Eliminating Variable Payload Costs and Cutting Mobile API Latency by 60%

---
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
---
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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": { ... } }
Enter fullscreen mode Exit fullscreen mode

Warm path (all subsequent requests):

GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123..."}}&variables={"id":"42"}
→ HTTP 200 (from CDN edge — origin never involved)
Enter fullscreen mode Exit fullscreen mode

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');
          }
        },
      }),
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)