Introduction to GraphQL Caching
Caching is crucial for optimizing GraphQL applications. Unlike REST APIs with predictable URLs, GraphQL's flexible query structure requires sophisticated caching strategies. Apollo Client provides powerful built-in caching capabilities that can dramatically improve application performance.
Apollo Client InMemory Cache
The default cache implementation normalizes data and stores it in a flat lookup table:
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
}
}
}
}
}
})
});
Cache Normalization
Apollo Client automatically normalizes your data by splitting query results into individual objects and storing them in a flat structure:
// Query Response
{
user: {
id: '1',
name: 'John Doe',
posts: [
{ id: '10', title: 'GraphQL Basics', author: { id: '1', name: 'John Doe' } }
]
}
}
// Normalized Cache
{
'User:1': { id: '1', name: 'John Doe', posts: [{ __ref: 'Post:10' }] },
'Post:10': { id: '10', title: 'GraphQL Basics', author: { __ref: 'User:1' } }
}
Note: Cache normalization requires objects to have an id or _id field, or you must define a custom keyFields configuration.
Cache Policies
Control how data is fetched and cached with fetch policies:
import { useQuery, gql } from '@apollo/client';
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
// Cache-first (default) - use cache, fallback to network
const { data } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'cache-first'
});
// Network-only - always fetch fresh data
const { data } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'network-only'
});
// Cache-only - never make network requests
const { data } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'cache-only'
});
// Cache-and-network - return cached data first, then update with network response
const { data } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'cache-and-network'
});
// No-cache - bypass cache completely
const { data } = useQuery(GET_USER, {
variables: { id: '1' },
fetchPolicy: 'no-cache'
});
Type Policies
Define custom behaviors for specific types in your schema:
const cache = new InMemoryCache({
typePolicies: {
User: {
keyFields: ['username'], // Use username instead of id
fields: {
fullName: {
read(_, { readField }) {
const firstName = readField('firstName');
const lastName = readField('lastName');
return `${firstName} ${lastName}`;
}
}
}
},
Post: {
fields: {
comments: {
merge(existing = [], incoming, { args }) {
if (args?.offset === 0) {
return incoming; // Replace if offset is 0
}
return [...existing, ...incoming]; // Append otherwise
}
}
}
}
}
});
Field Policies
Control reading and merging behavior for individual fields:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
// Define how to generate cache keys for this field
keyArgs: ['filter', 'sortBy'],
// Define how to merge paginated results
merge(existing = { items: [] }, incoming, { args }) {
const { offset = 0 } = args;
const merged = existing.items.slice(0);
for (let i = 0; i < incoming.items.length; i++) {
merged[offset + i] = incoming.items[i];
}
return {
...incoming,
items: merged
};
},
// Define how to read the field from cache
read(existing, { args }) {
if (!existing) return undefined;
const { offset = 0, limit = 10 } = args;
return {
...existing,
items: existing.items.slice(offset, offset + limit)
};
}
}
}
}
}
});
Tip: Use keyArgs to control which arguments affect cache keys. For example, keyArgs: ['filter'] means different filter values create separate cache entries, but pagination arguments like offset and limit don't.
Cache Redirects
Redirect queries to existing cached data to avoid duplicate network requests:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Redirect single user query to cached user from users list
user: {
read(_, { args, toReference }) {
return toReference({
__typename: 'User',
id: args.id
});
}
}
}
}
}
});
// If User:1 is already cached from a users query,
// this query will use cached data without making a network request
const { data } = useQuery(gql`
query GetUser {
user(id: "1") {
id
name
}
}
`);
Persisted Queries
Send query hashes instead of full query strings to reduce bandwidth and improve security:
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { createHttpLink } from '@apollo/client';
import { sha256 } from 'crypto-hash';
const httpLink = createHttpLink({
uri: 'https://api.example.com/graphql'
});
const persistedQueriesLink = createPersistedQueryLink({
sha256,
useGETForHashedQueries: true // Use GET requests for persisted queries
});
const client = new ApolloClient({
link: persistedQueriesLink.concat(httpLink),
cache: new InMemoryCache()
});
// First request sends full query + hash
// Subsequent requests only send hash
Note: Persisted queries require server-side support. The server must be able to store and retrieve queries by their SHA-256 hashes.
Cache Manipulation
Directly read from and write to the cache:
import { gql } from '@apollo/client';
// Read from cache
const cachedUser = client.readQuery({
query: gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`,
variables: { id: '1' }
});
// Write to cache
client.writeQuery({
query: gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`,
variables: { id: '1' },
data: {
user: {
__typename: 'User',
id: '1',
name: 'Jane Doe'
}
}
});
// Update cached fragment
client.writeFragment({
id: 'User:1',
fragment: gql`
fragment UpdateUser on User {
name
}
`,
data: {
name: 'Jane Updated'
}
});
// Evict from cache
client.cache.evict({ id: 'User:1' });
client.cache.gc(); // Garbage collect unreferenced objects
Warning: Manual cache manipulation can lead to inconsistencies. Always ensure your cached data matches your schema and includes required fields like __typename.
Cache Persistence
Persist the cache to localStorage for offline support:
import { InMemoryCache } from '@apollo/client';
import { persistCache } from 'apollo3-cache-persist';
const cache = new InMemoryCache();
async function setupApollo() {
await persistCache({
cache,
storage: window.localStorage,
maxSize: 1048576, // 1 MB
debug: true
});
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache
});
return client;
}
setupApollo().then(client => {
// Client is ready with persisted cache
});
Exercise: Create a GraphQL client with a custom cache configuration that:
- Uses username as the key field for User type
- Implements pagination merging for a posts feed
- Creates a cache redirect from a single post query to the posts list
- Persists the cache to localStorage
Test by fetching posts, then fetching a single post and verifying no network request is made.