GraphQL

Caching Strategies in GraphQL

18 min Lesson 21 of 35

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:
  1. Uses username as the key field for User type
  2. Implements pagination merging for a posts feed
  3. Creates a cache redirect from a single post query to the posts list
  4. Persists the cache to localStorage
Test by fetching posts, then fetching a single post and verifying no network request is made.