GraphQL Caching Strategies: How to Build a High-Performance Query Layer That Eliminates Redundant Fetches and Scales to Millions of Requests
GraphQL's flexible query model breaks traditional HTTP caching — here's the complete engineering playbook for building a multi-layer GraphQL caching strategy that cuts latency by 60% and eliminates redundant database hits at scale.
Quick Answer / TL;DR: GraphQL caching strategies require a fundamentally different approach than REST — you need to combine response-level caching (CDN + persisted queries), field-level caching via directives, DataLoader-based request deduplication, and Redis-backed resolver memoization to achieve sub-50ms query latency at scale. There is no single silver bullet; production systems need all four layers working in concert.
Why GraphQL Caching Strategies Are Harder Than REST — And Why That's a Solvable Problem
If you've ever migrated a high-traffic REST API to GraphQL and watched your database CPU spike to 90%, you already understand the problem. GraphQL caching strategies are fundamentally more complex than their REST counterparts because the entire value proposition of GraphQL — dynamic, client-driven queries — is the exact thing that makes naive HTTP caching useless. Every query can be structurally unique. POST requests don't cache at the CDN layer by default. And the N+1 query problem can silently destroy your database before you even realize what's happening.
At Apargo, we've architected GraphQL backends for SaaS platforms handling upwards of 4 million daily API calls. This article is the distilled playbook — covering every caching layer from the CDN edge down to the resolver level, with real code, real numbers, and the architectural decisions that actually matter in production.
The Four-Layer GraphQL Caching Model
Before diving into implementation, let's establish the mental model. Production-grade GraphQL caching strategies operate across four distinct layers, each targeting a different class of redundancy:
- Layer 1 — CDN / Edge Cache: Cache full query responses at the network edge using persisted queries and GET requests.
- Layer 2 — Application Response Cache: Cache entire operation results in Redis keyed by query hash + variables + user context.
- Layer 3 — Field-Level Cache Directives: Annotate individual schema fields with TTL-based cache hints that drive partial response caching.
- Layer 4 — DataLoader Deduplication: Batch and deduplicate resolver-level data fetches within a single request lifecycle to eliminate N+1 queries entirely.
Skipping any one of these layers leaves a significant performance gap. Let's build each one from the ground up.
Layer 1: CDN-Level Caching with Persisted Queries
The Problem with POST-Based GraphQL
Standard GraphQL clients send queries as HTTP POST requests with the query string in the body. CDNs like Cloudflare, Fastly, and AWS CloudFront do not cache POST requests by default — and for good reason, since POST semantics imply side effects. This means your CDN, which could be absorbing 70–80% of your traffic, is completely bypassed for every single GraphQL request.
The solution is Automatic Persisted Queries (APQ), a protocol supported natively by Apollo Client and Apollo Server. APQ works as follows:
- The client sends a lightweight GET request containing only the SHA-256 hash of the query.
- If the server recognizes the hash, it executes the cached query and returns the result.
- If not, the server returns a
PersistedQueryNotFounderror, prompting the client to re-send with the full query body, which the server then registers. - Subsequent requests use the hash-based GET, making them fully CDN-cacheable.
Here's the Apollo Server configuration to enable APQ with Redis-backed storage:
// apollo-server-setup.ts
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';
// Redis-backed APQ store — persisted queries survive server restarts
const redisStore = new KeyvRedis('redis://localhost:6379');
const keyvInstance = new Keyv({ store: redisStore });
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
// Enable cache control directives in schema
ApolloServerPluginCacheControl({ defaultMaxAge: 0 }),
],
// APQ store: queries are persisted here and served via GET
cache: new KeyvAdapter(keyvInstance),
persistedQueries: {
ttl: 300, // 5-minute TTL for persisted query registry
},
});
With APQ enabled and your CDN configured to cache GET requests, you can realistically absorb 40–60% of read traffic at the edge, never touching your origin server. For public or semi-public data (product catalogs, content feeds, pricing pages), this is the single highest-leverage optimization available.
Layer 2: Application-Level Response Caching with Redis
Keying Your Cache Correctly
For authenticated or personalized queries that can't be cached at the CDN, the next line of defense is an application-level response cache backed by Redis. The critical engineering challenge here is constructing a cache key that correctly captures all the dimensions of uniqueness for a given query result.
A naive implementation might key on just the query string — but this is dangerously wrong. Two users sending the same query should almost never receive each other's data. Your cache key must encode:
- The normalized query document (or its SHA-256 hash)
- The serialized variables object (deep-sorted to avoid key mismatches from property ordering)
- The authenticated user's ID or tenant ID (for multi-tenant SaaS)
- Any locale or currency context that affects the response
// cache-key-builder.ts
import crypto from 'crypto';
interface CacheKeyOptions {
queryHash: string;
variables: Record<string, unknown>;
userId?: string;
tenantId?: string;
locale?: string;
}
/**
* Builds a deterministic, collision-resistant Redis cache key
* for a GraphQL operation result.
*/
export function buildGraphQLCacheKey(options: CacheKeyOptions): string {
const { queryHash, variables, userId, tenantId, locale } = options;
// Deep-sort variables to ensure key stability regardless of property order
const sortedVars = JSON.stringify(variables, Object.keys(variables).sort());
const keyPayload = [
'gql',
queryHash,
sortedVars,
userId ?? 'anon',
tenantId ?? 'global',
locale ?? 'en',
].join(':');
// Hash the full payload to keep Redis key length manageable
return crypto.createHash('sha256').update(keyPayload).digest('hex');
}
Integrating the Cache Into Your Resolver Pipeline
Rather than littering individual resolvers with cache logic, wrap your execution at the operation level using a plugin or middleware. Here's an Apollo Server plugin that intercepts execution and serves from Redis when a valid cache entry exists:
// response-cache-plugin.ts
import { ApolloServerPlugin } from '@apollo/server';
import { Redis } from 'ioredis';
import { buildGraphQLCacheKey } from './cache-key-builder';
const redis = new Redis({ host: 'localhost', port: 6379 });
export const responseCache = (): ApolloServerPlugin => ({
async requestDidStart() {
return {
async executionDidStart(requestContext) {
const { request, contextValue } = requestContext;
const { query, variables, operationName } = request;
// Only cache Query operations — never Mutations or Subscriptions
if (!operationName || request.http?.method === 'POST') return;
const cacheKey = buildGraphQLCacheKey({
queryHash: operationName,
variables: variables ?? {},
userId: (contextValue as any).userId,
tenantId: (contextValue as any).tenantId,
});
const cached = await redis.get(cacheKey);
if (cached) {
// Serve cached response — avg 2–5ms vs 80–300ms from DB
return { result: JSON.parse(cached), cacheHit: true };
}
return {
async willSendResponse(responseContext) {
const { response } = responseContext;
if (!response.body.singleResult.errors) {
// Cache successful responses with a 60-second TTL
await redis.setex(cacheKey, 60, JSON.stringify(response.body.singleResult));
}
},
};
},
};
},
});
In production benchmarks at Apargo, this pattern consistently reduces average query latency from 180–300ms down to 8–15ms for cache-hit paths, with Redis serving responses at sub-5ms P99 latency.
Layer 3: Field-Level Cache Directives
Granular TTL Control Per Schema Field
Not all fields in your schema have the same staleness tolerance. A user's profile picture might be safely cacheable for 24 hours, while their unread notification count must always be fresh. GraphQL caching strategies that treat the entire response as a single cacheable unit are too coarse — you need field-level control.
Apollo Server's @cacheControl directive gives you exactly this. Apply it directly in your schema definition:
# schema.graphql
# Enum for controlling cache scope
enum CacheControlScope {
PUBLIC # Cacheable by CDN and shared caches
PRIVATE # Only cacheable in user-specific (private) caches
}
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
inheritMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
type Product @cacheControl(maxAge: 3600, scope: PUBLIC) {
id: ID!
name: String!
description: String!
price: Float! @cacheControl(maxAge: 300) # Price changes more often
inventory: Int! @cacheControl(maxAge: 30) # Inventory is highly volatile
images: [ProductImage!]! @cacheControl(maxAge: 86400, scope: PUBLIC)
}
type User @cacheControl(maxAge: 0, scope: PRIVATE) {
id: ID!
name: String!
email: String!
unreadNotifications: Int! @cacheControl(maxAge: 0) # Always fresh
avatar: String @cacheControl(maxAge: 86400, scope: PRIVATE)
}
Apollo Server computes the minimum maxAge across all fields touched by a query and uses that as the effective cache TTL for the response. This means a query that touches both Product.name (3600s) and Product.inventory (30s) will correctly be cached for only 30 seconds — the most volatile field wins.
Layer 4: DataLoader — Eliminating the N+1 Query Problem
Why N+1 Kills GraphQL Performance
The N+1 problem is the most common and most catastrophic performance issue in GraphQL backends. Consider a query that fetches 50 blog posts and the author of each. A naive resolver implementation will issue 1 query for posts and then 50 individual queries for authors — 51 total database round trips, each carrying 5–20ms of latency overhead. At scale, this translates to 250–1000ms of avoidable latency per request.
DataLoader, the open-source batching utility from the GraphQL Foundation, solves this by collecting all individual key lookups that occur within a single event loop tick and issuing a single batched query for all of them.
// dataloader-setup.ts
import DataLoader from 'dataloader';
import { db } from './database';
/**
* Creates a per-request DataLoader for User entities.
* IMPORTANT: DataLoader instances must be created per-request,
* not shared globally, to prevent cross-request data leakage.
*/
export function createUserLoader() {
return new DataLoader<string, User>(
async (userIds: readonly string[]) => {
// Single batched query replaces N individual queries
const users = await db.query(
`SELECT * FROM users WHERE id = ANY($1)`,Related Articles
Explore more insights from our engineering and product teams.
