Skip to content
Codeloom

Courses / GraphQL Complete Guide

Lesson 13 of 18

GraphQL Caching Strategies

Master GraphQL caching with client-side normalized caches, persisted queries, CDN caching, and server-side response caching.

Intermediate 13 min read

What you'll learn

  • How normalized client caches work and why they matter
  • How to configure cache policies and field-level merge functions
  • How persisted queries reduce bandwidth and improve security
  • How to implement server-side response caching
  • How to leverage CDN caching with GraphQL

Prerequisites

  • Basic GraphQL queries and mutations
  • Understanding of HTTP caching headers

Why GraphQL Caching Is Different

REST APIs map naturally to HTTP caching. Each URL is a cache key. GraphQL sends every request to a single endpoint with a POST body, so traditional URL-based caching does not apply. This does not mean GraphQL cannot be cached — it means you need different strategies.

There are three layers where caching happens: the client, the server, and the network edge (CDN). Each serves a different purpose, and a well-designed system uses all three.

Client-Side Normalized Caching

Apollo Client stores query results in a normalized cache. Instead of caching entire responses, it breaks them into individual objects keyed by __typename:id. This means if two queries return the same user, they share a single cache entry.

import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: '/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      User: {
        keyFields: ['id'],
      },
      Product: {
        keyFields: ['sku'],  // Use sku instead of id as the cache key
      },
      SearchResults: {
        keyFields: false,  // Do not normalize -- cache as nested data
      },
    },
  }),
});

When a query returns User { id: "1", name: "Alice" } and a later query returns User { id: "1", name: "Alice", email: "alice@example.com" }, the cache merges both into a single entity with all three fields. Every component watching that user automatically receives the updated data.

Fetch Policies

Apollo Client’s fetch policies control when to use the cache versus the network:

import { useQuery, gql } from '@apollo/client';

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
    }
  }
`;

// Default: check cache first, fetch from network only if not cached
const { data } = useQuery(GET_USER, {
  variables: { id: '1' },
  fetchPolicy: 'cache-first',
});

// Always fetch from network, but update cache for other components
const { data: fresh } = useQuery(GET_USER, {
  variables: { id: '1' },
  fetchPolicy: 'network-only',
});

// Show cached data immediately, then refresh in the background
const { data: stale } = useQuery(GET_USER, {
  variables: { id: '1' },
  fetchPolicy: 'cache-and-network',
});

// Never use the cache
const { data: uncached } = useQuery(GET_USER, {
  variables: { id: '1' },
  fetchPolicy: 'no-cache',
});

Use cache-first for data that rarely changes (user profiles, configurations). Use cache-and-network for data that should appear instantly but might be stale (social feeds, dashboards). Use network-only for data that must always be current (payment status, inventory counts).

Field Policies and Merge Functions

Sometimes the cache needs help merging data correctly. Paginated lists are the classic example — the cache does not know whether to replace or append results:

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ['type'],  // Separate caches for different feed types
          merge(existing = [], incoming, { args }) {
            if (args?.offset === 0) {
              return incoming;  // Fresh load, replace everything
            }
            return [...existing, ...incoming];  // Pagination, append
          },
        },
      },
    },
    Post: {
      fields: {
        comments: {
          merge(existing = [], incoming) {
            // Always replace comments with the latest data
            return incoming;
          },
        },
      },
    },
  },
});

Updating the Cache After Mutations

When a mutation changes data, you need to keep the cache in sync. There are three strategies:

Refetching is the simplest. Tell Apollo to re-run specific queries after the mutation completes:

const [createPost] = useMutation(CREATE_POST, {
  refetchQueries: [
    { query: GET_FEED, variables: { type: 'LATEST' } },
    'GetUserPosts',  // Refetch any active query with this name
  ],
});

Returning updated data from the mutation lets the cache update automatically:

const [updateUser] = useMutation(UPDATE_USER);

// The mutation returns the full updated user
// Apollo matches it by __typename + id and updates the cache
await updateUser({
  variables: { id: '1', input: { name: 'New Name' } },
});

Manual cache updates give you full control:

const [deletePost] = useMutation(DELETE_POST, {
  update(cache, { data: { deletePost } }) {
    cache.modify({
      fields: {
        feed(existingPosts = [], { readField }) {
          return existingPosts.filter(
            (postRef) => readField('id', postRef) !== deletePost.id
          );
        },
      },
    });
  },
});

Persisted Queries

Every GraphQL request sends the full query string in the request body. For complex queries, this can be kilobytes of text sent with every request. Automatic Persisted Queries (APQ) replace the query string with a hash:

import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

const link = createPersistedQueryLink({ sha256 });

const client = new ApolloClient({
  link: link.concat(httpLink),
  cache: new InMemoryCache(),
});

The flow works like this:

  1. Client sends a request with only the query hash (no query body)
  2. If the server recognizes the hash, it executes the query
  3. If not, it returns a “persisted query not found” error
  4. Client retries with the full query body, and the server caches the hash-to-query mapping
  5. All subsequent requests use only the hash

This reduces bandwidth and enables GET requests, which unlocks HTTP caching and CDN support.

Server-Side Response Caching

Cache entire GraphQL responses on the server using cache hints in the schema:

type Query {
  products(category: String): [Product!]! @cacheControl(maxAge: 300)
  currentUser: User @cacheControl(maxAge: 0, scope: PRIVATE)
}

type Product @cacheControl(maxAge: 3600) {
  id: ID!
  name: String!
  price: Float! @cacheControl(maxAge: 60)  # Price changes more often
  description: String!
}

Set up the cache control plugin in Apollo Server:

import { ApolloServer } from '@apollo/server';
import responseCachePlugin from '@apollo/server-plugin-response-cache';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    responseCachePlugin({
      sessionId: (requestContext) => {
        // Requests from the same user share a private cache
        return requestContext.request.http?.headers.get('authorization') || null;
      },
    }),
  ],
});

The server calculates the overall cache TTL as the minimum maxAge across all fields in the response. If a query returns both products (maxAge: 300) and a price field (maxAge: 60), the response is cached for 60 seconds.

CDN Caching

To cache GraphQL responses at the CDN edge, you need GET requests with deterministic URLs. Persisted queries enable this:

// With APQ, requests become GET requests with query params:
// GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123","version":1}}

// The CDN can cache based on the URL

Configure your CDN to cache based on the full URL including query parameters. Set Cache-Control headers from your server based on the cache control hints:

import { ApolloServer } from '@apollo/server';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    {
      async requestDidStart() {
        return {
          async willSendResponse({ response, overallCachePolicy }) {
            if (overallCachePolicy.maxAge > 0) {
              const scope = overallCachePolicy.scope === 'PRIVATE' ? 'private' : 'public';
              response.http.headers.set(
                'Cache-Control',
                `${scope}, max-age=${overallCachePolicy.maxAge}`
              );
            }
          },
        };
      },
    },
  ],
});

For Cloudflare, Fastly, or Vercel’s edge network, this means frequently requested queries are served from the edge without ever hitting your origin server.

Redis-Based Server Cache

For multi-instance deployments, use Redis as a shared response cache:

import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    responseCachePlugin({
      cache: new KeyvAdapter(
        new Keyv({ store: new KeyvRedis('redis://localhost:6379') })
      ),
    }),
  ],
});

DataLoader for Per-Request Caching

DataLoader caches database lookups within a single request, preventing duplicate fetches when the same entity is referenced multiple times:

import DataLoader from 'dataloader';

function createLoaders(db) {
  return {
    userLoader: new DataLoader(async (ids) => {
      const users = await db.users.findByIds(ids);
      const userMap = new Map(users.map((u) => [u.id, u]));
      return ids.map((id) => userMap.get(id) || null);
    }),
  };
}

// Create fresh loaders for each request
const server = new ApolloServer({ typeDefs, resolvers });

app.use(
  '/graphql',
  expressMiddleware(server, {
    context: async ({ req }) => ({
      loaders: createLoaders(db),
    }),
  })
);

DataLoader batches multiple .load(id) calls within the same tick into a single batch query, and caches results so the same ID is never fetched twice in the same request.

Choosing the Right Caching Layer

StrategyScopeBest For
Normalized client cachePer clientReducing network requests, instant UI updates
Fetch policiesPer queryFine-tuning freshness vs speed tradeoffs
Persisted queriesClient to serverBandwidth reduction, enabling GET/CDN caching
Server response cachePer server/clusterExpensive computations, rarely-changing data
CDN edge cacheGlobalHigh-traffic public data, static content
DataLoaderPer requestPreventing N+1 queries within a single request
Redis cachePer clusterSharing cached responses across server instances

Start with client-side caching and DataLoader — they give the most impact with the least effort. Add server-side response caching for expensive queries. Add CDN caching when you need global performance at scale.

Progress is saved locally to your browser.