GraphQL Caching Strategies
Master GraphQL caching with client-side normalized caches, persisted queries, CDN caching, and server-side response caching.
What you'll learn
- ✓How normalized client caches work and why they matter
- ✓How to configure cache policies and field-level merge functions
- ✓How persisted queries reduce bandwidth and improve security
- ✓How to implement server-side response caching
- ✓How to leverage CDN caching with GraphQL
Prerequisites
- •Basic GraphQL queries and mutations
- •Understanding of HTTP caching headers
Why GraphQL Caching Is Different
REST APIs map naturally to HTTP caching. Each URL is a cache key. GraphQL sends every request to a single endpoint with a POST body, so traditional URL-based caching does not apply. This does not mean GraphQL cannot be cached — it means you need different strategies.
There are three layers where caching happens: the client, the server, and the network edge (CDN). Each serves a different purpose, and a well-designed system uses all three.
Client-Side Normalized Caching
Apollo Client stores query results in a normalized cache. Instead of caching entire responses, it breaks them into individual objects keyed by __typename:id. This means if two queries return the same user, they share a single cache entry.
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: '/graphql',
cache: new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id'],
},
Product: {
keyFields: ['sku'], // Use sku instead of id as the cache key
},
SearchResults: {
keyFields: false, // Do not normalize -- cache as nested data
},
},
}),
});
When a query returns User { id: "1", name: "Alice" } and a later query returns User { id: "1", name: "Alice", email: "alice@example.com" }, the cache merges both into a single entity with all three fields. Every component watching that user automatically receives the updated data.
Fetch Policies
Apollo Client’s fetch policies control when to use the cache versus the network:
import { useQuery, gql } from '@apollo/client';
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
// Default: check cache first, fetch from network only if not cached
const { data } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'cache-first',
});
// Always fetch from network, but update cache for other components
const { data: fresh } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'network-only',
});
// Show cached data immediately, then refresh in the background
const { data: stale } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'cache-and-network',
});
// Never use the cache
const { data: uncached } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'no-cache',
});
Use cache-first for data that rarely changes (user profiles, configurations). Use cache-and-network for data that should appear instantly but might be stale (social feeds, dashboards). Use network-only for data that must always be current (payment status, inventory counts).
Field Policies and Merge Functions
Sometimes the cache needs help merging data correctly. Paginated lists are the classic example — the cache does not know whether to replace or append results:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
keyArgs: ['type'], // Separate caches for different feed types
merge(existing = [], incoming, { args }) {
if (args?.offset === 0) {
return incoming; // Fresh load, replace everything
}
return [...existing, ...incoming]; // Pagination, append
},
},
},
},
Post: {
fields: {
comments: {
merge(existing = [], incoming) {
// Always replace comments with the latest data
return incoming;
},
},
},
},
},
});
Updating the Cache After Mutations
When a mutation changes data, you need to keep the cache in sync. There are three strategies:
Refetching is the simplest. Tell Apollo to re-run specific queries after the mutation completes:
const [createPost] = useMutation(CREATE_POST, {
refetchQueries: [
{ query: GET_FEED, variables: { type: 'LATEST' } },
'GetUserPosts', // Refetch any active query with this name
],
});
Returning updated data from the mutation lets the cache update automatically:
const [updateUser] = useMutation(UPDATE_USER);
// The mutation returns the full updated user
// Apollo matches it by __typename + id and updates the cache
await updateUser({
variables: { id: '1', input: { name: 'New Name' } },
});
Manual cache updates give you full control:
const [deletePost] = useMutation(DELETE_POST, {
update(cache, { data: { deletePost } }) {
cache.modify({
fields: {
feed(existingPosts = [], { readField }) {
return existingPosts.filter(
(postRef) => readField('id', postRef) !== deletePost.id
);
},
},
});
},
});
Persisted Queries
Every GraphQL request sends the full query string in the request body. For complex queries, this can be kilobytes of text sent with every request. Automatic Persisted Queries (APQ) replace the query string with a hash:
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = createPersistedQueryLink({ sha256 });
const client = new ApolloClient({
link: link.concat(httpLink),
cache: new InMemoryCache(),
});
The flow works like this:
- Client sends a request with only the query hash (no query body)
- If the server recognizes the hash, it executes the query
- If not, it returns a “persisted query not found” error
- Client retries with the full query body, and the server caches the hash-to-query mapping
- All subsequent requests use only the hash
This reduces bandwidth and enables GET requests, which unlocks HTTP caching and CDN support.
Server-Side Response Caching
Cache entire GraphQL responses on the server using cache hints in the schema:
type Query {
products(category: String): [Product!]! @cacheControl(maxAge: 300)
currentUser: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
type Product @cacheControl(maxAge: 3600) {
id: ID!
name: String!
price: Float! @cacheControl(maxAge: 60) # Price changes more often
description: String!
}
Set up the cache control plugin in Apollo Server:
import { ApolloServer } from '@apollo/server';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
sessionId: (requestContext) => {
// Requests from the same user share a private cache
return requestContext.request.http?.headers.get('authorization') || null;
},
}),
],
});
The server calculates the overall cache TTL as the minimum maxAge across all fields in the response. If a query returns both products (maxAge: 300) and a price field (maxAge: 60), the response is cached for 60 seconds.
CDN Caching
To cache GraphQL responses at the CDN edge, you need GET requests with deterministic URLs. Persisted queries enable this:
// With APQ, requests become GET requests with query params:
// GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123","version":1}}
// The CDN can cache based on the URL
Configure your CDN to cache based on the full URL including query parameters. Set Cache-Control headers from your server based on the cache control hints:
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
return {
async willSendResponse({ response, overallCachePolicy }) {
if (overallCachePolicy.maxAge > 0) {
const scope = overallCachePolicy.scope === 'PRIVATE' ? 'private' : 'public';
response.http.headers.set(
'Cache-Control',
`${scope}, max-age=${overallCachePolicy.maxAge}`
);
}
},
};
},
},
],
});
For Cloudflare, Fastly, or Vercel’s edge network, this means frequently requested queries are served from the edge without ever hitting your origin server.
Redis-Based Server Cache
For multi-instance deployments, use Redis as a shared response cache:
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
cache: new KeyvAdapter(
new Keyv({ store: new KeyvRedis('redis://localhost:6379') })
),
}),
],
});
DataLoader for Per-Request Caching
DataLoader caches database lookups within a single request, preventing duplicate fetches when the same entity is referenced multiple times:
import DataLoader from 'dataloader';
function createLoaders(db) {
return {
userLoader: new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids);
const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) || null);
}),
};
}
// Create fresh loaders for each request
const server = new ApolloServer({ typeDefs, resolvers });
app.use(
'/graphql',
expressMiddleware(server, {
context: async ({ req }) => ({
loaders: createLoaders(db),
}),
})
);
DataLoader batches multiple .load(id) calls within the same tick into a single batch query, and caches results so the same ID is never fetched twice in the same request.
Choosing the Right Caching Layer
| Strategy | Scope | Best For |
|---|---|---|
| Normalized client cache | Per client | Reducing network requests, instant UI updates |
| Fetch policies | Per query | Fine-tuning freshness vs speed tradeoffs |
| Persisted queries | Client to server | Bandwidth reduction, enabling GET/CDN caching |
| Server response cache | Per server/cluster | Expensive computations, rarely-changing data |
| CDN edge cache | Global | High-traffic public data, static content |
| DataLoader | Per request | Preventing N+1 queries within a single request |
| Redis cache | Per cluster | Sharing cached responses across server instances |
Start with client-side caching and DataLoader — they give the most impact with the least effort. Add server-side response caching for expensive queries. Add CDN caching when you need global performance at scale.
Related articles
- GraphQL GraphQL Caching with Apollo Client
How Apollo Client's normalized cache works, why entity IDs matter, and the patterns for cache updates, refetches, and consistent UI after mutations.
- REST APIs REST API Caching: ETags, Cache-Control, and CDN Patterns
Master HTTP caching for REST APIs. Learn ETags, Cache-Control headers, conditional requests, and CDN integration patterns with practical examples.
- GraphQL GraphQL Federation for Microservices
Build a unified GraphQL API from multiple microservices using Apollo Federation, subgraphs, and gateway composition.
- GraphQL Solving the N+1 Problem in GraphQL with DataLoader
Learn how the DataLoader pattern batches and caches database calls to eliminate N+1 query performance issues in GraphQL APIs.