Apollo Client Caching
Complete guide to mastering Apollo Client's powerful caching system
Apollo Client is a powerhouse for handling GraphQL in frontend apps—and one of its greatest strengths is its flexible and efficient caching system. Mastering this can drastically improve performance, reduce network load, and make your app feel snappier and smarter.
InMemoryCache
Apollo's default cache, InMemoryCache, stores data in memory and normalizes it. This means each piece of data is stored once and reused everywhere it's needed—making lookups faster and updates more efficient.
Basic Setup
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://your-graphql-endpoint.com/graphql',
cache: new InMemoryCache({
typePolicies: {
// Customize how different types are cached
}
})
});Normalized Cache
Apollo breaks down complex query results into individual entities and stores them flatly using unique identifiers like id and __typename. This approach helps keep the cache clean and ensures updates ripple through the app automatically.
How Normalization Works
// Query result
const userQuery = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
content
}
}
}
`;
// Apollo automatically normalizes this into:
// User:1 -> { id: "1", name: "John", email: "john@example.com" }
// Post:1 -> { id: "1", title: "First Post", content: "..." }
// Post:2 -> { id: "2", title: "Second Post", content: "..." }Cache Redirects
Redirects let you reroute a query to already-cached data—even if it was stored under a different query. Handy for cases like fetching a user by ID when you've already loaded a list of users.
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
user: {
read(existing, { args, toReference }) {
// If we're looking for a user by ID, check if it's already in cache
if (args?.id) {
return toReference({
__typename: 'User',
id: args.id
});
}
return existing;
}
}
}
}
}
});Advanced Cache Redirects
// Redirect queries to cached data
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
post: {
read(existing, { args, toReference, cache }) {
if (args?.slug) {
// Try to find post by slug in existing cache
const existingPosts = cache.readQuery({
query: gql`query GetPosts { posts { id slug title } }`
});
const post = existingPosts?.posts?.find(p => p.slug === args.slug);
if (post) {
return toReference({
__typename: 'Post',
id: post.id
});
}
}
return existing;
}
}
}
}
}
});Field Policies
Field policies give you control over how fields are read and merged in the cache. They're great for custom merging strategies, computed fields, and things like cursor-based pagination.
Basic Field Policy
const cache = new InMemoryCache({
typePolicies: {
User: {
fields: {
fullName: {
read(existing, { readField }) {
const firstName = readField('firstName');
const lastName = readField('lastName');
return `${firstName} ${lastName}`;
}
},
posts: {
merge(existing = [], incoming) {
// Custom merge strategy for posts array
return [...existing, ...incoming];
}
}
}
}
}
});Pagination with Field Policies
import { relayStylePagination } from '@apollo/client/utilities';
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: relayStylePagination(['category', 'author']),
// Or use offsetLimitPagination for offset-based pagination
// posts: offsetLimitPagination(['category', 'author'])
}
},
User: {
fields: {
posts: relayStylePagination()
}
}
}
});Custom Pagination Strategy
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['category', 'author'], // Cache separately by these args
merge(existing = { data: [], hasMore: true }, incoming, { args }) {
if (args?.offset === 0) {
// First page, replace everything
return incoming;
}
// Append to existing data
return {
data: [...existing.data, ...incoming.data],
hasMore: incoming.hasMore
};
}
}
}
}
}
});Cache Eviction & Garbage Collection
Apollo offers built-in tools to evict specific pieces of cached data when they're no longer needed. Garbage collection automatically cleans up unreferenced entries to keep memory usage in check.
Manual Cache Eviction
// Evict specific objects
cache.evict({ id: 'Post:123' });
cache.evict({ id: 'User:456', fieldName: 'posts' });
// Evict by field
cache.evict({
id: cache.identify({ __typename: 'User', id: '456' }),
fieldName: 'posts'
});
// Clear entire cache
cache.reset();
// Garbage collection
cache.gc();Automatic Cache Cleanup
// Set up automatic cleanup
const cache = new InMemoryCache({
typePolicies: {
Post: {
fields: {
comments: {
merge(existing = [], incoming) {
// Keep only last 50 comments to prevent memory bloat
const merged = [...existing, ...incoming];
return merged.slice(-50);
}
}
}
}
}
});
// Periodic cleanup
setInterval(() => {
cache.gc();
}, 5 * 60 * 1000); // Every 5 minutesOptimistic UI
Optimistic responses let your UI update immediately while the mutation is still in flight—making your app feel ultra-responsive. Apollo automatically reconciles the UI once the server responds.
Basic Optimistic Update
const ADD_TODO = gql`
mutation AddTodo($text: String!) {
addTodo(text: $text) {
id
text
completed
}
}
`;
function AddTodo() {
const [addTodo] = useMutation(ADD_TODO);
const handleAddTodo = async (text: string) => {
await addTodo({
variables: { text },
optimisticResponse: {
addTodo: {
__typename: 'Todo',
id: `temp-${Date.now()}`,
text,
completed: false
}
},
update(cache, { data }) {
// Update cache with real data when mutation completes
cache.modify({
fields: {
todos(existingTodos = []) {
const newTodoRef = cache.writeFragment({
data: data.addTodo,
fragment: gql`fragment NewTodo on Todo {
id
text
completed
}`
});
return [...existingTodos, newTodoRef];
}
}
});
}
});
};
return (
<button onClick={() => handleAddTodo('New Task')}>
Add Todo
</button>
);
}Complex Optimistic Updates
const UPDATE_POST = gql`
mutation UpdatePost($id: ID!, $title: String!) {
updatePost(id: $id, title: $title) {
id
title
updatedAt
}
}
`;
function UpdatePost({ postId, currentTitle }) {
const [updatePost] = useMutation(UPDATE_POST);
const handleUpdate = async (newTitle: string) => {
await updatePost({
variables: { id: postId, title: newTitle },
optimisticResponse: {
updatePost: {
__typename: 'Post',
id: postId,
title: newTitle,
updatedAt: new Date().toISOString()
}
},
update(cache, { data }) {
// Update all queries that include this post
cache.modify({
id: cache.identify({ __typename: 'Post', id: postId }),
fields: {
title() {
return data.updatePost.title;
},
updatedAt() {
return data.updatePost.updatedAt;
}
}
});
}
});
};
return (
<input
defaultValue={currentTitle}
onBlur={(e) => handleUpdate(e.target.value)}
/>
);
}Fetch Policies
Apollo provides fetch policies to control the flow between cache and network:
Available Policies
// Default: Use cache if available, otherwise hit the network
const { data } = useQuery(GET_POSTS, {
fetchPolicy: 'cache-first'
});
// Always fetch from network, but cache the result
const { data } = useQuery(GET_POSTS, {
fetchPolicy: 'network-only'
});
// Serve cache first, then update in background
const { data } = useQuery(GET_POSTS, {
fetchPolicy: 'cache-and-network'
});
// Don't store anything in cache
const { data } = useQuery(GET_POSTS, {
fetchPolicy: 'no-cache'
});
// Use cache only, don't hit network
const { data } = useQuery(GET_POSTS, {
fetchPolicy: 'cache-only'
});
// Use cache if available and not stale, otherwise network
const { data } = useQuery(GET_POSTS, {
fetchPolicy: 'cache-first',
nextFetchPolicy: 'cache-first'
});Dynamic Fetch Policies
function PostList({ category, isOffline }) {
const { data } = useQuery(GET_POSTS, {
variables: { category },
fetchPolicy: isOffline ? 'cache-only' : 'cache-and-network',
errorPolicy: 'all'
});
return (
<div>
{data?.posts?.map(post => (
<Post key={post.id} post={post} />
))}
</div>
);
}Advanced Cache Patterns
Custom Cache Keys
const cache = new InMemoryCache({
typePolicies: {
Post: {
keyFields: ['id', 'locale'], // Custom key fields
fields: {
// Computed fields
displayTitle: {
read(existing, { readField }) {
const title = readField('title');
const locale = readField('locale');
return `${title} (${locale})`;
}
}
}
}
}
});Cache Watchers
// Watch for cache changes
cache.watch((diff) => {
console.log('Cache changed:', diff);
// React to specific changes
if (diff.result && diff.result.user) {
console.log('User data updated:', diff.result.user);
}
});Cache Performance Monitoring
// Monitor cache performance
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
merge(existing, incoming, { fieldName }) {
console.time(`merge-${fieldName}`);
const result = [...(existing || []), ...incoming];
console.timeEnd(`merge-${fieldName}`);
return result;
}
}
}
}
}
});Performance Best Practices
Cache Optimization Checklist
- Use appropriate fetch policies for different data types
- Implement proper field policies for arrays and relationships
- Set up cache redirects for efficient data reuse
- Use optimistic updates for better UX
- Implement cache persistence for offline support
- Monitor cache size and implement cleanup strategies
- Use cache watchers for debugging and monitoring
- Optimize cache keys for better performance
Memory Management
// Implement cache size limits
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
merge(existing = [], incoming) {
const merged = [...existing, ...incoming];
// Keep only last 100 posts to prevent memory bloat
return merged.slice(-100);
}
}
}
}
}
});
// Regular cleanup
setInterval(() => {
cache.gc();
}, 10 * 60 * 1000); // Every 10 minutesFinal Thoughts
By understanding and leveraging Apollo Client's caching system, you can drastically improve app performance and create seamless, real-time-like experiences with minimal complexity. The key is to:
- Understand normalization - how Apollo stores data efficiently
- Use field policies - for custom merge strategies and computed fields
- Implement optimistic updates - for responsive UI
- Choose appropriate fetch policies - based on data freshness requirements
- Monitor and optimize - cache performance and memory usage
Apollo's caching system is incredibly powerful—master it, and your GraphQL apps will feel lightning-fast!
Next: IndexedDB Guide - Learn how to implement robust client-side storage for your applications.