Complete Guide to GraphQL Caching
Cache GraphQL responses at every layer: CDN, gateway, DataLoader, persisted queries, and client-side. Covers cache keys, invalidation, HTTP caching directives, and Apollo Client cache.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Introduction
GraphQL caching is harder than REST caching because every request goes to the same URL (/graphql) with a different POST body. REST can cache at the URL level; GraphQL needs cache keys based on the query content. Despite this, there are multiple layers where you can cache GraphQL data effectively. The following guide covers each layer from CDN to client, with code examples and tradeoffs.
Caching Layers
Client Cache (Apollo Client) → CDN/Edge Cache → Gateway Cache → DataLoader (per-request) → Database
Each layer serves a different purpose:
- Client cache: Avoids redundant network requests for the same data
- CDN/edge cache: Serves responses close to users geographically
- Gateway cache: Caches subgraph responses to reduce subgraph load
- DataLoader: Batches and caches within a single request to prevent N+1
- Database cache: Caches query results at the ORM or database level
HTTP Caching with GET Requests
Switch from POST to GET
By default, GraphQL clients send POST requests. POST responses are not cacheable by CDNs or browsers. Switch to GET for cacheable queries.
// Apollo Client: use GET for queries
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
const client = new ApolloClient({
link: new HttpLink({
uri: "/graphql",
useGETForQueries: true,
}),
cache: new InMemoryCache(),
});
The server must support GET requests with the query in the URL:
// Express server
app.get("/graphql", (req, res) => {
const { query, variables, operationName } = req.query;
// Execute and return
});
Cache-Control Directives
Use the @cacheControl directive to set max-age and scope on types and fields.
type Query {
product(id: ID!): Product @cacheControl(maxAge: 3600)
products: [Product!]! @cacheControl(maxAge: 600)
currentUser: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
type Product @cacheControl(maxAge: 3600) {
id: ID!
name: String!
price: Float!
}
type User @cacheControl(maxAge: 0, scope: PRIVATE) {
id: ID!
email: String!
}
The server calculates the cache policy for each query based on the fields requested. If a query includes any PRIVATE field, the entire response is private. The max-age is the minimum of all fields’ max-age values.
import { ApolloServerPluginCacheControl } from "@apollo/server/plugin/cacheControl";
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 0 })],
});
The plugin sets Cache-Control: max-age=3600, public or Cache-Control: max-age=0, private headers on responses.
CDN Caching
How CDN Caching Works for GraphQL
When using GET requests with cache-control headers, CDNs (Cloudflare, Fastly, CloudFront) cache responses based on the full URL including query string.
GET /graphql?query={product(id:1){id name price}}&variables={}
The CDN stores the response and serves it directly for identical URLs. This works well for public, non-user-specific data.
Cache Key Considerations
The cache key is the full URL. Two queries that differ only in whitespace produce different cache keys. Use persisted queries to normalize cache keys.
Persisted Queries for CDN Caching
With persisted queries, the client sends a hash instead of the full query:
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123","version":1}}
All clients using the same query produce the same URL, maximizing CDN cache hits.
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
import { sha256 } from "crypto-hash";
const persistedQueryLink = createPersistedQueryLink({ sha256 });
const httpLink = new HttpLink({ uri: "/graphql", useGETForQueries: true });
const client = new ApolloClient({
link: persistedQueryLink.concat(httpLink),
cache: new InMemoryCache(),
});
CDN Purge on Data Changes
When data changes, purge the CDN cache. Use webhooks or API calls to the CDN provider.
// After updating a product
async function purgeProductCache(productId) {
await fetch("https://api.fastly.com/purge/abc123", {
method: "POST",
headers: { "Fastly-Key": process.env.FASTLY_KEY },
body: JSON.stringify({ surrogates: [`product-${productId}`] }),
});
}
Use surrogate keys in the Surrogate-Key response header to tag responses for targeted purging:
res.setHeader("Surrogate-Key", `product-${productId} products`);
Gateway-Level Caching
Apollo Router Cache
Apollo Router can cache subgraph responses. This reduces load on subgraphs for repeated queries.
# router.yaml
supergraph:
cache:
enabled: true
ttl: 300s
Entity Cache
Cache entity resolution results so repeated entity references do not hit the subgraph.
# router.yaml
apq:
router:
cache:
in_memory:
limit: 1000
DataLoader: Per-Request Caching
DataLoader batches and caches within a single GraphQL request. It prevents N+1 queries by grouping individual loads into one batch.
import DataLoader from "dataloader";
const resolvers = {
Query: {
products: async (_root, { ids }, ctx) => {
const products = await ctx.db.products.findMany({ where: { id: { in: ids } } });
return products;
},
},
Product: {
category: (product, _args, ctx) => ctx.loaders.categoryLoader.load(product.categoryId),
},
};
// Context factory: create fresh DataLoaders per request
function createContext(db) {
return {
db,
loaders: {
categoryLoader: new DataLoader(async (categoryIds) => {
const categories = await db.categories.findMany({ where: { id: { in: categoryIds } } });
const map = new Map(categories.map((c) => [c.id, c]));
return categoryIds.map((id) => map.get(id));
}),
},
};
}
DataLoader Caching Within a Request
DataLoader caches by key within a single request. If two resolvers call load(42), the database is queried once. The second call returns the cached result. This cache is per-request: a new request gets fresh DataLoaders.
DataLoader vs Redis Cache
DataLoader is a per-request cache. Redis is a cross-request cache. Use both: DataLoader prevents N+1 within a request, Redis prevents redundant database queries across requests.
const categoryLoader = new DataLoader(async (categoryIds) => {
// Check Redis first
const cached = await ctx.redis.mget(categoryIds.map((id) => `category:${id}`));
const missing = categoryIds.filter((id, i) => !cached[i]);
// Fetch missing from database
if (missing.length > 0) {
const fromDb = await db.categories.findMany({ where: { id: { in: missing } } });
await Promise.all(fromDb.map((c) => ctx.redis.set(`category:${c.id}`, JSON.stringify(c), "EX", 3600)));
}
// Merge cached and fresh
return categoryIds.map((id, i) => cached[i] ? JSON.parse(cached[i]) : fromDb.find((c) => c.id === id));
});
Client-Side Caching with Apollo Client
Normalized Cache
Apollo Client stores data in a normalized cache by __typename:id. This means updating a product in one query updates it everywhere.
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Product: {
keyFields: ["id"],
},
Query: {
fields: {
products: {
merge(existing = [], incoming) {
return incoming;
},
},
},
},
},
}),
});
Cache Updates After Mutations
After a mutation, update the cache to reflect the change without refetching.
const CREATE_PRODUCT = gql`
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) {
product { id name price }
}
}
`;
const GET_PRODUCTS = gql`
query GetProducts {
products { id name price }
}
`;
function CreateProduct() {
const [createProduct] = useMutation(CREATE_PRODUCT, {
update(cache, { data }) {
const newProduct = data.createProduct.product;
cache.modify({
fields: {
products(existing = []) {
cache.writeFragment({
data: newProduct,
fragment: gql`fragment NewProduct on Product { id name price }`,
});
return [...existing, newProduct];
},
},
});
},
});
// ...
}
Cache Persistence
Persist the cache to localStorage or sessionStorage so it survives page reloads.
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { LocalStorageWrapper, persistCache } from "apollo3-cache-persist";
const cache = new InMemoryCache();
await persistCache({
cache,
storage: new LocalStorageWrapper(window.localStorage),
maxSize: 1048576, // 1MB
});
Cache Invalidation Strategies
TTL-Based Expiration
Set a time-to-live on cached data. After the TTL expires, the next request fetches fresh data. Simple but can serve stale data for up to the TTL duration.
// Redis SET with TTL
await redis.set("product:42", JSON.stringify(product), "EX", 3600); // 1 hour
Event-Driven Invalidation
Publish invalidation events when data changes. Subscribers delete the cache entry.
// After updating a product
async function updateProduct(id, data) {
const product = await db.products.update({ where: { id }, data });
await redis.del(`product:${id}`);
await redis.publish("cache-invalidation", JSON.stringify({ type: "product", id }));
return product;
}
// Subscriber
redis.subscribe("cache-invalidation", (message) => {
const { type, id } = JSON.parse(message);
redis.del(`${type}:${id}`);
});
Versioned Cache Keys
Include a version number in the cache key. Bump the version when data changes. Old cache entries expire naturally.
const version = await redis.get("product:version") || "1";
const cacheKey = `product:${id}:v${version}`;
const cached = await redis.get(cacheKey);
Tag-Based Invalidation
Tag cache entries with related entities. Purge by tag.
// Set with tags
await redis.set("product:42", JSON.stringify(product), "EX", 3600);
await redis.sadd("tag:category:5", "product:42");
// Purge by tag
async function purgeCategory(categoryId) {
const keys = await redis.smembers(`tag:category:${categoryId}`);
if (keys.length > 0) {
await redis.del(...keys);
await redis.del(`tag:category:${categoryId}`);
}
}
What to Cache vs What Not to Cache
Cache
- Public, read-heavy data (product catalogs, blog posts, categories)
- Data that changes infrequently (configurations, reference data)
- Aggregated data (counts, summaries, reports)
- User-specific data with short TTL (profile, preferences)
Do Not Cache
- Real-time data (stock prices, live scores)
- Sensitive data requiring fresh reads (account balance, medical records)
- Data behind mutations that must be immediately consistent
- Authentication tokens and session data
Production Checklist
- GET requests enabled for cacheable queries
-
@cacheControldirectives on public types and fields - Persisted queries enabled for consistent CDN cache keys
- CDN configured to cache
publicresponses - CDN purge mechanism for data changes
- DataLoader for all list and relationship resolvers
- Redis cache for frequently accessed entities
- Apollo Client normalized cache configured
- Cache updates after mutations (no stale data)
- Cache persistence for offline support (if needed)
- Monitoring for cache hit rate at each layer
- TTLs set appropriately per data type
FAQ
Why can’t I cache GraphQL like REST?
REST caches by URL. Each resource has a unique URL, so the CDN or browser can cache it. GraphQL sends all requests to /graphql, so the URL is the same for every query. To cache GraphQL, you need GET requests with the query in the URL, or persisted queries that produce consistent cache keys.
Should I cache mutations?
No. Mutations change data and must reach the server. Only cache queries (read operations). The @cacheControl directive only applies to query responses.
How long should I cache data?
Depends on how stale the data can be. Product catalogs: 1 hour. User profiles: 5 minutes. Configurations: 24 hours. Real-time data: 0 (no cache). Set the TTL to the longest acceptable staleness for each data type.
What is the difference between Apollo Client cache and server cache?
Apollo Client cache is in the browser. It prevents redundant network requests and enables instant UI updates after mutations. Server cache (CDN, Redis, DataLoader) prevents redundant database queries and computation. Both layers are needed for a fast application.
How do I test cache behavior?
Test that repeated queries return cached results (check response headers for Age and X-Cache: HIT). Test that mutations invalidate the cache. Test that stale data is not served after updates. Use Apollo Client’s client.cache.extract() to inspect the client cache.
Should I use Redis or Memcached for GraphQL caching?
Redis supports structured data (hashes, sets, sorted sets), TTLs, and pub/sub for cache invalidation. Memcached is simpler and faster for key-value caching. Use Redis if you need tag-based invalidation or pub/sub. Use Memcached for simple TTL-based caching.
See Also
Related Resources
Complete Guide to GraphQL Schema Design
Design GraphQL schemas for evolution, performance, and maintainability. Covers type design, connections, mutations, error handling, deprecation, and schema-first vs code-first workflows.
GuideComplete Guide to GraphQL Security
Secure GraphQL APIs against introspection leaks, query depth attacks, cost-based DoS, batching abuse, and injection. Covers auth patterns, rate limiting, and production hardening.
PatternGraphQL DataLoader Pattern
Coalesce individual load requests into batched calls with per-request caching to prevent N+1 queries and redundant fetches.
GuideComplete Guide to GraphQL Testing
Test GraphQL APIs at every layer: unit tests for resolvers, integration tests for schema, E2E tests for operations. Covers mocking, fixtures, snapshot testing, and performance testing.
GuideComplete Guide to CDN Caching Strategy
Design CDN caching for web applications and APIs. Covers edge caching, cache keys, cache headers, invalidation strategies, surrogate keys, and multi-CDN setups for global performance.
GuideComplete Guide to Redis Caching Strategies
Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.