Cache Strategies
Caching turns GraphQL from "every screen hits the network" into "every screen feels instant". The layers: normalised client cache (Apollo, urql, Relay), HTTP cache for persisted queries, response cache on the server, DataLoader for per-request dedup, and a real cache like Redis for query-level memoisation.
Client cache + persisted queries + Redis on the server
EXAMPLE
// ===== 1) Client cache (Apollo) — normalised by type + id =====
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: '/api/graphql',
cache: new InMemoryCache({
typePolicies: {
Order: { keyFields: ['id'] },
Query: {
fields: {
orders: {
keyArgs: ['status'],
merge(existing = { data: [] }, incoming) {
return { ...incoming, data: [...existing.data, ...incoming.data] };
},
},
},
},
},
}),
});
// Subsequent queries asking for a known Order's id hit cache.
// Mutations that return the updated Order auto-merge.
// ===== 2) Default fetch policy: cache-and-network =====
const ORDERS = gql\`query { orders { id customer status } }\`;
const { data } = useQuery(ORDERS, {
fetchPolicy: 'cache-and-network', // show cached, then refetch silently
});
// ===== 3) Manual cache writes for optimistic UI =====
import { useMutation } from '@apollo/client';
const CANCEL = gql\`mutation Cancel($id: ID!) { cancelOrder(id: $id) { id status } }\`;
const [cancel] = useMutation(CANCEL, {
optimisticResponse: ({ id }) => ({
cancelOrder: { __typename: 'Order', id, status: 'cancelled' },
}),
update(cache, { data }) {
cache.modify({
id: cache.identify({ __typename: 'Order', id: data!.cancelOrder.id }),
fields: { status: () => data!.cancelOrder.status },
});
},
});
// ===== 4) Persisted queries — server-side caching key =====
// The client sends only a hash; the server looks up the query.
// Operators (CDN, varnish) can cache the response by hash.
// Apollo:
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
import { ApolloLink, HttpLink, ApolloClient, InMemoryCache } from '@apollo/client';
const link = createPersistedQueryLink({ sha256 }).concat(new HttpLink({ uri: '/graphql' }));
const persistedClient = new ApolloClient({ link, cache: new InMemoryCache() });
// ===== 5) Server: DataLoader for per-request dedup =====
// (See graphql/dataloader lesson for full example.)
const ordersLoader = new DataLoader(async (ids) => {
const rows = await db.orders.find({ id: { $in: [...ids] } }).toArray();
return ids.map((id) => rows.find((r) => r.id === id) ?? null);
});
// ===== 6) Server: response cache (Apollo Cache Control + Redis) =====
// Schema directives describe TTLs:
// type Order @cacheControl(maxAge: 60) { id customer status @cacheControl(maxAge: 5) }
// Backend extracts the directive + caches in Redis.
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
new ApolloServer({
schema,
plugins: [
ApolloServerPluginCacheControl({ defaultMaxAge: 30 }),
responseCachePlugin({
// pluggable cache backend (in-memory by default; swap in Redis)
}),
],
});
// ===== 7) Field-level resolvers cached in Redis =====
import Redis from 'ioredis';
const r = new Redis();
const resolvers = {
Query: {
async products(_, { categoryId }) {
const key = \`products:cat:${categoryId}\`;
const cached = await r.get(key);
if (cached) return JSON.parse(cached);
const products = await db.products.findByCategory(categoryId);
await r.set(key, JSON.stringify(products), 'EX', 300);
return products;
},
},
};
// Invalidation on mutation:
// Mutation.updateProduct: await r.del(\`products:cat:${cat}\`);
// ===== 8) HTTP layer (CDN) caching =====
// With persisted queries, GET requests can be cached by Cloudflare, Fastly, etc.
// Configure:
// - Persisted query map at the edge (cache by query hash)
// - Vary: Authorization for user-scoped queries
// - Surrogate-Key headers for invalidation by tag
// ===== 9) Decision matrix =====
// - SPA reads same data across screens -> client normalised cache (Apollo / urql / Relay)
// - Same query at scale, mostly anonymous -> persisted queries + CDN
// - Expensive resolver (DB, third party) -> server response cache (Redis)
// - N+1 risk (one user fetches 100 orders' authors) -> DataLoader
// - Heavy compute per query (recommendations) -> precompute + cache by user
// ===== 10) Pitfalls =====
// - Caching mutations (do not) -> stale UI
// - Cache key without 'status' filter -> all-status response served for 'open' query
// - User-scoped data cached at CDN without Vary: Authorization -> data leak
// - DataLoader at module scope -> cross-tenant leak; create per-request
// - Forgetting cache invalidation on mutation -> stale forever
Why it matters
Layer your cache: client normalised cache for SPA reads, DataLoader for per-request dedup, Redis for expensive resolvers, persisted queries + CDN for anonymous scale. Each catches what the others miss; together they turn "every screen calls graphql" into "every screen reads from memory" — without manual cache plumbing in the components.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Apollo cache normalises by __typename + id. // Update cache after a mutation with cache.modify(...).Try it Yourself »
Discussion
Loading…