Name:
interface
Value:
Amplify has re-imagined the way frontend developers build fullstack applications. Develop and deploy without the hassle.

Page updated May 1, 2026

LegacyYou are viewing Gen 1 documentation. Switch to the latest Gen 2 docs →

Optional: Add local caching

The Set up Apollo Client page gave you a working Apollo Client with auth, error handling, retry logic, and new InMemoryCache(). That cache lives in memory only -- every time the user refreshes the page or reopens the app, every query starts from scratch with a network request and a loading spinner.

This page adds persistent caching and optimistic updates on top of that foundation. You will configure Apollo's cache to survive page refreshes by persisting it to IndexedDB, gate your app startup on cache restoration, choose the right fetch policy for each query, implement instant UI updates for mutations, and manage cache size with eviction and purge on sign-out.

Install persistence libraries

npm install apollo3-cache-persist localforage
  • apollo3-cache-persist (v0.15.0) -- persists Apollo's InMemoryCache to a storage backend
  • localforage (v1.10.0) -- provides an IndexedDB storage backend with automatic fallback

Set up CachePersistor with IndexedDB

Configure localforage

import localforage from 'localforage';
localforage.config({
driver: localforage.INDEXEDDB,
name: 'myapp-apollo-cache',
storeName: 'apollo_cache',
});

Create the CachePersistor

import { CachePersistor, LocalForageWrapper } from 'apollo3-cache-persist';
export const persistor = new CachePersistor({
cache,
storage: new LocalForageWrapper(localforage),
maxSize: 1048576 * 2, // 2MB -- increase if your app caches large datasets
debug: process.env.NODE_ENV === 'development',
trigger: 'write',
key: 'apollo-cache-v1', // Bump when your GraphQL schema changes
});

Use CachePersistor instead of persistCache. The convenience function persistCache does not return the persistor instance, which means you cannot call purge() (needed for sign-out), pause()/resume(), or getSize(). For any production app, CachePersistor is the right choice.

Configuration options

OptionDefaultPurpose
cache(required)The InMemoryCache instance to persist
storage(required)Storage wrapper -- use LocalForageWrapper for IndexedDB
maxSize1048576 (1MB)Max persisted size in bytes. Set false to disable the limit
trigger'write'When to persist: 'write' (on every cache write), 'background' (on tab visibility change)
debounce1000Milliseconds to wait between persist writes
key'apollo-cache-persist'Storage key identifier. Version this to invalidate stale caches
debugfalseLog persistence activity to the console
Enhanced Apollo Client setup with cache persistence

Here is the complete enhanced src/apolloClient.ts that builds on the setup from the previous page. The link chain (retry, error, auth, HTTP) is unchanged -- only the cache configuration, persistor, and default fetch policy are new.

src/apolloClient.ts
import {
ApolloClient,
InMemoryCache,
createHttpLink,
from,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { CachePersistor, LocalForageWrapper } from 'apollo3-cache-persist';
import localforage from 'localforage';
import { fetchAuthSession } from 'aws-amplify/auth';
import config from '../amplifyconfiguration.json';
// --- Configure IndexedDB via localforage ---
localforage.config({
driver: localforage.INDEXEDDB,
name: 'myapp-apollo-cache',
storeName: 'apollo_cache',
});
// --- InMemoryCache ---
const cache = new InMemoryCache({
typePolicies: {
// See typePolicies section below for full configuration
},
});
// --- Cache Persistor ---
export const persistor = new CachePersistor({
cache,
storage: new LocalForageWrapper(localforage),
maxSize: 1048576 * 2,
debug: process.env.NODE_ENV === 'development',
trigger: 'write',
key: 'apollo-cache-v1',
});
// --- Links (unchanged from Set up Apollo Client page) ---
const httpLink = createHttpLink({ uri: config.aws_appsync_graphqlEndpoint });
const authLink = setContext(async (_, { headers }) => {
try {
const session = await fetchAuthSession();
const token = session.tokens?.idToken?.toString();
return { headers: { ...headers, authorization: token || '' } };
} catch (error) {
console.error('Auth session error:', error);
return { headers };
}
});
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
for (const { message, locations, path } of graphQLErrors) {
console.error(`[GraphQL error]: ${message}, ${locations}, ${path}`);
}
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
}
});
const retryLink = new RetryLink({
delay: { initial: 300, max: 5000, jitter: true },
attempts: { max: 3, retryIf: (error) => !!error },
});
// --- Apollo Client ---
export const apolloClient = new ApolloClient({
link: from([retryLink, errorLink, authLink, httpLink]),
cache,
defaultOptions: {
watchQuery: { fetchPolicy: 'cache-and-network' },
query: { fetchPolicy: 'cache-and-network' },
},
});

Cache restoration on app startup

Queries that fire before persistor.restore() completes see an empty InMemoryCache. Not gating renders on cache restoration is the most common persistence mistake. The symptom is loading spinners on every app launch despite having cached data in IndexedDB.

Call await persistor.restore() before rendering any component that uses Apollo queries:

Once cacheReady flips to true, every useQuery hook inside ApolloProvider will find the restored cache data and render immediately -- no network request needed for data that was cached in a previous session.

Fetch policy patterns

Fetch policies control where Apollo reads data from -- cache, network, or both -- on a per-query basis.

PolicyCache ReadNetwork FetchBest For
cache-firstYes (if data exists)Only on cache missData that rarely changes
cache-and-networkYes (immediate)Always (updates cache after)Recommended default. Shows cached data instantly, then updates from server.
network-onlyNoAlwaysForce fresh data after a conflict error
cache-onlyYesNeverTrue offline reads
no-cacheNoAlwaysOne-off sensitive reads
standbyYesOnly on manual refetch()Inactive queries

DataStore migration mapping

DataStore PatternRecommended fetchPolicyWhy
DataStore.query(Model) (online)cache-and-networkReturns cached data immediately, then updates from server
DataStore.query(Model) (offline)cache-onlyReads from persistent cache with no network attempt
DataStore.observeQuery()cache-and-network with useQueryShows cache first, updates on server response
After conflict errornetwork-onlyForces fresh data from server to resolve stale state

DataStore always showed locally cached data immediately and then synced with the server in the background. cache-and-network is the closest Apollo equivalent:

  1. The query reads from cache first (instant render, no loading spinner)
  2. Apollo fires a network request in the background
  3. When the response arrives, the cache updates and the component re-renders with fresh data

Enhanced sign-out with cache purge

Order matters for sign-out: pause, clearStore, purge, signOut. If you skip the purge step, the next user who signs in will see the previous user's cached data restored from disk.

src/auth.ts
import { signOut } from 'aws-amplify/auth';
import { apolloClient, persistor } from './apolloClient';
export async function handleSignOut() {
// 1. Pause persistence so clearStore doesn't trigger a write
persistor.pause();
// 2. Clear in-memory cache and cancel active queries
await apolloClient.clearStore();
// 3. Purge persisted cache from IndexedDB
await persistor.purge();
// 4. Sign out from Amplify (clears Cognito tokens)
await signOut();
}

Why this order matters:

  1. Pause first -- clearStore() modifies the cache, which would trigger the persistor to write an empty cache to IndexedDB. Pausing prevents that unnecessary write.
  2. Clear in-memory cache -- removes all cached data from memory and cancels active queries.
  3. Purge IndexedDB -- deletes the persisted cache from disk so the next user starts fresh.
  4. Sign out last -- clears Cognito tokens. If you sign out first, clearStore() may trigger refetches that fail because the auth token is already invalidated.

Optimistic updates

The Migrate CRUD operations page showed how to create, update, and delete records using Apollo mutations with refetchQueries. That approach waits for the server response before the UI updates. Optimistic updates replace refetchQueries with instant UI updates that show changes before the server confirms.

DataStore updated its local store synchronously on save(). Apollo's optimistic layer achieves the same instant-UI behavior, but you write it explicitly.

How optimistic updates work

When you provide an optimisticResponse to a mutation, Apollo:

  1. Caches the optimistic object in a separate layer (does not overwrite canonical cache data)
  2. Active queries re-render immediately with the optimistic data
  3. When the server responds, the optimistic layer is discarded and the canonical cache updates
  4. On error, the optimistic layer is discarded and the UI reverts automatically -- zero rollback code needed

Optimistic create

const [createPost] = useMutation(CREATE_POST, {
optimisticResponse: ({ input }) => ({
createPost: {
__typename: 'Post',
id: `temp-${Date.now()}`,
title: input.title,
content: input.content,
status: input.status,
rating: input.rating ?? null,
_version: 1,
_deleted: false,
_lastChangedAt: Date.now(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
}),
update(cache, { data }) {
if (!data?.createPost) return;
cache.updateQuery({ query: LIST_POSTS }, (existing) => {
if (!existing?.listPosts) return existing;
return {
listPosts: {
...existing.listPosts,
items: [data.createPost, ...existing.listPosts.items],
},
};
});
},
});

The update function is needed for creates because Apollo's normalized cache cannot know that a brand-new object should appear in an existing list query.

Optimistic update

const [updatePost] = useMutation(UPDATE_POST, {
optimisticResponse: {
updatePost: {
__typename: 'Post',
id: post.id,
title: 'Updated Title',
content: post.content,
status: post.status,
rating: 4,
_version: post._version + 1,
_deleted: false,
_lastChangedAt: Date.now(),
createdAt: post.createdAt,
updatedAt: new Date().toISOString(),
},
},
// No update function needed -- Apollo auto-merges by __typename + id
});

Optimistic delete

const [deletePost] = useMutation(DELETE_POST, {
optimisticResponse: {
deletePost: {
__typename: 'Post',
id: post.id,
_version: post._version + 1,
_deleted: true,
_lastChangedAt: Date.now(),
},
},
update(cache, { data }) {
if (!data?.deletePost) return;
cache.evict({ id: cache.identify(data.deletePost) });
cache.gc();
},
});

_version in optimistic responses

OperationOptimistic _versionWhy
Create1New records start at version 1
Updatepost._version + 1Predicts the server's version increment
Deletepost._version + 1The delete mutation increments the version

The optimistic _version does not need to be exact. The server response always replaces the optimistic data in the canonical cache.

typePolicies for pagination and soft-delete filtering

Pagination merge

Without typePolicies, Apollo treats each (limit, nextToken) combination as a separate cache entry. A "Load More" button would replace page 1 with page 2 instead of appending.

import { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
listPosts: {
keyArgs: ['filter'],
merge(existing, incoming) {
if (!existing) return incoming;
return {
...incoming,
items: [...(existing.items || []), ...(incoming.items || [])],
};
},
read(existing, { readField }) {
if (!existing) return existing;
return {
...existing,
items: existing.items.filter(
(ref) => !readField('_deleted', ref)
),
};
},
},
},
},
},
});

keyArgs: ['filter'] tells Apollo that queries with the same filter share a cache entry (pages merge), while different filters are separate entries.

Why readField instead of direct property access

In Apollo's normalized cache, list items are stored as references (for example, { __ref: "Post:123" }), not as full objects. You cannot access ref._deleted directly. The readField helper resolves the reference and reads the field from the normalized cache entry.

// WRONG -- ref is a cache reference, not the actual object
items.filter((ref) => !ref._deleted)
// CORRECT -- readField resolves the reference
items.filter((ref) => !readField('_deleted', ref))
Complete typePolicies configuration
const cache = new InMemoryCache({
typePolicies: {
Post: { keyFields: ['id'] },
Comment: { keyFields: ['id'] },
Query: {
fields: {
listPosts: {
keyArgs: ['filter'],
merge(existing, incoming) {
if (!existing) return incoming;
return {
...incoming,
items: [...(existing.items || []), ...(incoming.items || [])],
};
},
read(existing, { readField }) {
if (!existing) return existing;
return {
...existing,
items: existing.items.filter(
(ref) => !readField('_deleted', ref)
),
};
},
},
listComments: {
keyArgs: ['filter'],
merge(existing, incoming) {
if (!existing) return incoming;
return {
...incoming,
items: [...(existing.items || []), ...(incoming.items || [])],
};
},
read(existing, { readField }) {
if (!existing) return existing;
return {
...existing,
items: existing.items.filter(
(ref) => !readField('_deleted', ref)
),
};
},
},
},
},
},
});

The pattern is the same for every list query: keyArgs for filter separation, merge for pagination, read for soft-delete filtering. Add a field policy for each list query in your schema.

Cache size management

Monitor cache size

async function logCacheSize() {
const sizeInBytes = await persistor.getSize();
if (sizeInBytes !== null) {
console.log(`Cache size: ${(sizeInBytes / 1024).toFixed(1)} KB`);
}
}

maxSize behavior

When the serialized cache exceeds maxSize, the persistor stops writing to IndexedDB silently. The in-memory cache continues to work normally. Enable debug: true during development to see console warnings.

Schema version strategy

When your GraphQL schema changes, bump the key option on your CachePersistor (for example, from 'apollo-cache-v1' to 'apollo-cache-v2'). This starts with an empty cache -- one cold start in exchange for zero cache migration code.

Troubleshooting local caching

Cache not restored before queries run: Every page load shows loading spinners briefly. Gate your app rendering on persistor.restore() completion.

Cache exceeds maxSize silently: Recent data is not persisted across refreshes. Increase maxSize to 2-5MB and enable debug: true.

Stale cache after schema changes: App crashes with TypeErrors reading cached data. Bump the version in the key option.

Duplicate items after create: Apollo calls the update function twice for optimistic mutations (once for optimistic, once for server response). Rely on Apollo's optimistic layer lifecycle, or add an existence check in the update function.

_deleted records still showing: Use readField('_deleted', ref) in the read function, not direct property access.