iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…