Apollo Client GraphQL Caching

Apollo Client’s InMemoryCache is the client tier of the Data Fetching & Caching Strategies stack: it normalizes GraphQL responses from a headless CMS into an entity store keyed by __typename plus a stable identifier, so a field updated by one query updates every component that references the same entry.

GraphQL already trims over-fetching at the query layer. What the normalized cache adds is deduplication across queries, consistent UI state across route transitions and a place to apply optimistic writes while an editor’s mutation is still in flight. It is also the tier most likely to drift from the CMS, because nothing on the server tells the browser that an entry changed. This page covers the whole contract: how the cache identifies CMS entries, how lists merge, how publishes reach it, and how to test and observe it in production.

How a query resolves against the normalized storeA component query first checks the entity store for every reference; complete hits resolve locally, misses go to the CMS GraphQL endpoint and are flattened back into the store by typename and key fields.useQueryArticlePageAll refsin store?Resolve fromentity storeCMS GraphQLendpointFlatten bytypename + keyFieldsRenderhitmissresponsewrite refs
Normalization is why one entry fetched by two different queries is stored once and updated in both places.

Integration Contract

Apollo Client talks to a single GraphQL endpoint per client instance. Contentful, Hygraph, Sanity (through its GraphQL deployment), Strapi (with the GraphQL plugin) and Directus all expose one, but they differ in what they put in the id field and in how draft content is addressed. The contract you need to pin down before writing any cache configuration has three parts.

The endpoint and its auth model. Delivery endpoints take a read-only token that can be exposed to the browser only if the CMS scopes it to published content. Preview endpoints take a separate token that must stay on the server. Never ship a preview token in a client bundle: anyone who opens DevTools can then read unpublished entries.

The identity field for every cacheable type. Normalization only works when each type returns a stable, unique key. Contentful returns sys { id }, Hygraph returns id, Strapi v4 returns id inside a data { id attributes } wrapper and Sanity returns _id. If you do not request the key field, Apollo cannot normalize the object and stores it inline under its parent, which silently disables cross-query updates for that type.

The locale and environment dimensions. Many CMS APIs return the same entry id in every locale. If the locale is not part of the cache key, the French page can render English fields that another route already cached.

Bash
# .env.local — one client per endpoint, never mix tokens in one instance
NEXT_PUBLIC_CMS_GRAPHQL_URL=https://graphql.contentful.com/content/v1/spaces/abc123/environments/master
NEXT_PUBLIC_CMS_DELIVERY_TOKEN=cda_public_read_only_token
CMS_PREVIEW_GRAPHQL_URL=https://graphql.contentful.com/content/v1/spaces/abc123/environments/master
CMS_PREVIEW_TOKEN=cpa_server_only_preview_token
APOLLO_CACHE_MAX_ENTRIES=5000

The NEXT_PUBLIC_ prefix is a deliberate signal: only the delivery URL and its published-only token are allowed into the browser bundle. The preview pair is read exclusively by server code and by the draft-mode route.

Core Implementation Pattern

The cache configuration lives in typePolicies, which is the control plane for how content types are identified, merged and read. A production setup for a CMS usually needs three kinds of policy: key fields for entry types, merge functions for paginated lists and read functions for derived or defaulted fields.

TypeScript
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import type { FieldPolicy, Reference } from "@apollo/client";

interface ArticleConnection {
  items: Reference[];
  total: number;
}

// Offset pagination as Contentful's collection fields expose it.
const collectionPolicy: FieldPolicy<ArticleConnection> = {
  keyArgs: ["where", "order", "locale", "preview"],
  merge(existing, incoming, { args }) {
    const skip = (args?.skip as number | undefined) ?? 0;
    const items = existing ? existing.items.slice(0) : [];
    incoming.items.forEach((item, i) => {
      items[skip + i] = item;
    });
    return { ...incoming, items };
  },
};

export function createCmsClient(locale: string): ApolloClient<unknown> {
  return new ApolloClient({
    link: new HttpLink({
      uri: process.env.NEXT_PUBLIC_CMS_GRAPHQL_URL,
      headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_CMS_DELIVERY_TOKEN ?? ""}` },
    }),
    cache: new InMemoryCache({
      typePolicies: {
        // Contentful puts id and locale under sys; keying on both keeps translations apart.
        Article: { keyFields: ["sys", ["id", "locale"]] },
        Author: { keyFields: ["sys", ["id"]] },
        Query: { fields: { articleCollection: collectionPolicy } },
      },
    }),
    defaultOptions: { watchQuery: { fetchPolicy: "cache-and-network" } },
  });
}

Three decisions in that snippet carry most of the weight. The nested keyFields path reads the identifier and locale out of sys instead of adding a normalized id in a response transform, so the cache key matches the CMS’s own id. Including sys.locale in the key makes each translation a separate entity. That costs some memory and prevents a whole class of mixed-language bugs. And keyArgs on the collection field lists the arguments that define a different list (filter, order, locale, preview) but leaves out skip and limit, so pages of the same list merge into one array instead of fragmenting into one cache entry per page.

The typePolicies and keyFields guide walks through the identity rules for each major CMS. Cursor pagination merge functions covers the list case for APIs that use cursors instead of offsets.

Identity fields by CMS GraphQL APIWhich field each headless CMS returns as the stable entry identifier, and the keyFields value that normalizes it in Apollo.CMSIdentity in responsekeyFields valueLocale in key?Contentfulsys.id["sys", ["id", "locale"]]sys.localeHygraphiddefaultadd locale fieldSanity GraphQL_id["_id"]drafts use drafts. prefixStrapi v4data.iddefault on Entitylocale per entry idDirectusiddefaulttranslations are rows
If the identity field is not requested in every query, Apollo cannot normalize the object at all.

Caching & Invalidation Strategy

The Apollo cache is one of several caches between the reader and the CMS. The data path is browser memory, then the framework’s server cache or ISR output, then the CDN edge, then the CMS’s own delivery CDN. Each tier has its own freshness rule, and a publish event has to reach all of them.

The browser tier is the hardest to invalidate because the server cannot reach it. There are three practical ways to get fresh data into an open tab:

  1. Fetch policy. cache-and-network renders from the cache immediately and fires a network request in the background, then updates the component if anything changed. It suits editorial content where a few seconds of staleness is acceptable. cache-first never refetches data it already holds, which suits immutable content such as versioned documentation.
  2. Polling or focus refetch. pollInterval on a query refetches on a timer. That is wasteful for long-lived pages but works for dashboards or live blogs.
  3. Push invalidation. A CMS webhook reaches your server, which broadcasts entry ids over Server-Sent Events or a WebSocket; the client calls cache.evict for those ids and cache.gc() to drop orphaned references. The eviction guide implements this end to end, including HMAC verification of the webhook.
Publish event reaching an open browser tabThe CMS sends a signed webhook, the server verifies it and broadcasts the entry id, and each connected client evicts the entry and refetches the queries that watched it.CMSWebhook routeSSE channelApollo cachePOST entry.publish + signatureverify HMACdedupe by delivery idpublish {id, type, locale}event: invalidatecache.evict(ref)cache.gc()refetch active queries
The browser cache is invalidated by broadcast, not by the webhook itself; the server is the only party that can verify the signature.

Fetch policies by content type

Not every content type needs the same freshness. A reasonable default table for a marketing or documentation site looks like this:

Content type fetchPolicy nextFetchPolicy Reasoning
Navigation, footer, site settings cache-first cache-first Changes rarely; push eviction covers the exceptions.
Articles, product pages cache-and-network cache-first Render instantly, refresh once on mount, then trust the cache.
Prices, stock, event schedules network-only cache-and-network Staleness has a business cost; always confirm with the API.
Search results no-cache n/a Unique per query string; caching them only grows memory.

nextFetchPolicy is the setting most often forgotten. Without it, a query with cache-and-network refetches every time its variables change and every time the cache is written by another query that touches the same entities. That can mean a CMS request on every “load more” click elsewhere on the page. Setting nextFetchPolicy: "cache-first" limits the network round trip to the first execution.

Memory budgets for long sessions

Readers of documentation sites and editors in preview tools keep tabs open for hours. Apollo never evicts on its own, so the store grows with every page visited. Two controls keep it bounded. Call cache.gc() on route changes, which drops entities no active query can reach. For single-page apps with thousands of routes, keep a small LRU of recently visited slugs, evict root fields for slugs that fall out of it, and then collect garbage. A few thousand entities are negligible; tens of thousands of rich-text documents are not, especially on low-memory mobile devices.

CDN headers matter for the GraphQL requests themselves too. Most CMS GraphQL endpoints accept only POST, which shared caches do not store. If you front the CMS with your own gateway and use persisted queries sent as GET, the edge can cache them and a Cache-Control: public, s-maxage=60, stale-while-revalidate=300 response shields the CMS from traffic spikes. The persisted queries guide covers the gateway side.

Schema & Content Modeling Considerations

The shape of the content model decides how large and how interconnected the normalized store becomes. Deeply nested references, such as a page referencing sections that reference cards that reference authors, normalize well: each entity is stored once, however many pages include it. Rich text fields are the opposite. Contentful’s rich text JSON, Sanity’s Portable Text and Strapi’s blocks field are opaque scalars to GraphQL, so Apollo stores them inline and cannot deduplicate the entries they embed. Request embedded entries through the explicit links or reference fields the CMS provides, not by parsing them out of the JSON, so they normalize like any other entity.

Union and interface types need possibleTypes configured, or Apollo cannot match fragments against the objects in the cache. A modular page builder that returns HeroSection | CardGrid | Testimonial is the common case. Generate possibleTypes from the schema at build time with GraphQL Code Generator so a new section type added by a content modeler does not silently break fragment matching in production.

Payload size follows query shape, not cache configuration. A query that asks for bodyCollection(limit: 100) stores a hundred entities whether the page renders them or not. Keep list limits aligned with what the UI shows, and use the content modeling guidance to keep reference depth shallow enough that CMS query-complexity limits are not hit.

Preview & Draft Workflow

Draft content should never enter the same cache instance as published content. Every draft/publish state pattern on this site assumes that separation, and Apollo makes it easy to get wrong because a single client can issue both kinds of query. Three rules keep preview safe:

  • Separate client instances. Create the preview client only inside draft mode, with the preview endpoint and a server-held token. Published pages keep using the delivery client.
  • A preview dimension in cache keys. If you must share a client, as some visual editors require, add preview to keyArgs for collection fields and to keyFields for entry types, so the draft and published versions of an entry are distinct entities.
  • No persistence of preview caches. If you use apollo3-cache-persist for offline support, disable it in draft mode. A persisted draft cache can resurface unpublished content after the editor has left preview.

Live preview makes the difference visible. Contentful’s live preview SDK and Sanity’s visual editing both push updated field values into the page while the editor types. With Apollo, apply those updates via cache.writeFragment against the entry’s normalized id, so every component showing that entry re-renders without a network round trip.

Error Handling & Resilience

GraphQL returns partial data: a response can contain both data and errors when one resolver fails. The default errorPolicy: "none" throws the whole result away in that case, which turns one missing reference into a blank page. For CMS content, errorPolicy: "all" is usually the better default. It renders what arrived and reports the rest.

TypeScript
import { onError } from "@apollo/client/link/error";
import { RetryLink } from "@apollo/client/link/retry";

export const retryLink = new RetryLink({
  delay: { initial: 300, max: 4000, jitter: true },
  attempts: {
    max: 4,
    // Retry network failures and CMS rate limits, never validation errors.
    retryIf: (error: { statusCode?: number } | undefined) =>
      !!error && (error.statusCode === undefined || error.statusCode === 429 || error.statusCode >= 500),
  },
});

export const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
  for (const err of graphQLErrors ?? []) {
    console.warn(JSON.stringify({ level: "warn", op: operation.operationName, path: err.path, msg: err.message }));
  }
  if (networkError) {
    console.error(JSON.stringify({ level: "error", op: operation.operationName, msg: networkError.message }));
  }
});

Put RetryLink before HttpLink in the chain and onError before RetryLink. Errors are then logged once per operation, not once per attempt. Rate limits deserve special attention: Contentful’s delivery API allows a fixed number of requests per second per space, and a burst of client-side refetches after a broadcast invalidation can exceed it. Jittered backoff spreads the retries; batching evictions on the server side (one broadcast per 500 ms window) prevents the burst in the first place.

A circuit breaker belongs in front of the CMS for server-side rendering, not in the browser. If the delivery API starts failing, server renders should stop calling it for a cool-down period and serve the last cached HTML or data instead of queuing retries that all time out. In the browser, the equivalent is simply not retrying forever. Four attempts with jittered backoff is plenty, after which the error link reports the failure and the UI keeps showing cached content.

Typed errors make the difference between a useful error boundary and a generic one. Wrap Apollo’s ApolloError in a small discriminated union ({ kind: "not-found" } | { kind: "rate-limited"; retryAfter: number } | { kind: "unavailable" }) at the data-access layer, so components branch on meaning instead of parsing messages. A not-found result for a slug should render the 404 route; a rate-limited one should keep the cached page and schedule a retry after the Retry-After interval the CMS returned.

For fallback content, keep the last good result. With cache-and-network, a failed background refetch leaves the cached data on screen and sets error on the result; render a small “content may be out of date” notice instead of replacing the page with an error state.

Testing & Observability

Cache behaviour is testable without a running CMS. MockedProvider from @apollo/client/testing accepts a pre-configured InMemoryCache, so a unit test can seed the cache with a normalized article, render the component, fire an eviction and assert that the component refetches. The same approach verifies that two queries sharing an entry stay in sync. That is the property most likely to break when someone removes a key field from a fragment. The automated testing for headless integrations topic covers contract tests against recorded CMS responses, which catch the other failure mode: a schema change on the CMS side that removes a field the cache policy depends on.

Test layers for an Apollo-backed CMS frontendFour test layers, from cache-policy unit tests up to production tracing, each catching a different class of cache fault.UnittypePolicies logicseeded InMemoryCacheevict + refetchmerge policyContractCMS schema driftrecorded CMS responsesschema diffEnd to endwebhook to UI pathpublish then assertpreview isolationProductionreal trafficoperation tracescache sizehit ratio
Each layer catches faults the others cannot see; cache-policy unit tests are the cheapest and catch the most.

In production, log operation names and timing from a custom link, and sample cache.extract() sizes in long-lived sessions to catch unbounded growth. A cache whose entity count climbs steadily usually has a list field without a merge policy (each page stored separately) or an entry type with an unstable key such as a timestamped id.

Frequently Asked Questions

Should I use Apollo Client or React Query for a headless CMS?

Choose Apollo when the CMS is GraphQL-first and the same entries appear across many queries, because normalization keeps them consistent automatically. Choose React Query for CMS data when the API is REST or when pages fetch self-contained documents, where normalization adds configuration without much benefit.

Why does my Apollo cache show the wrong language after switching locales?

The entry id is identical across locales in most CMS APIs, so both translations normalize to the same cache key and the last one written wins. Add the locale to keyFields for localized types and to keyArgs for collection fields, or create one client per locale.

Does cache-and-network double my CMS API usage?

It issues a background request whenever a watched query mounts, so yes, repeat visits cost one request each instead of zero. For content that changes rarely, use cache-first together with push-based eviction, which keeps request counts low and still delivers publishes promptly.

How do I clear the Apollo cache when an editor publishes?

The browser never sees the webhook, so a server route must verify it and broadcast the changed ids to connected clients, which then call cache.evict and cache.gc. Calling client.resetStore() also works but refetches every active query at once, which can trip CMS rate limits.