Implementing Tag-Based Invalidation in Apollo Client

Apollo Client’s InMemoryCache stores every GraphQL response as a normalized entity graph keyed by __typename + a deterministic ID field. When a mutation resolves, the default behaviour only updates fields that appear verbatim in the mutation response — leaving every other query that touches the same entity blissfully unaware. The symptom is familiar: a user edits a record, the mutation succeeds, and the list view still shows the old value. This page walks through the complete workflow for wiring declarative, entity-scoped invalidation inside Tag-Based Invalidation Systems so that Apollo evicts or rewrites exactly the right normalized entries after each mutation.

For the broader context of when to use tag-based eviction versus polling or event-driven revalidation, see the Cache Invalidation & Server Synchronization overview. Engineers working in non-GraphQL stacks may find the sibling recipe Optimizing SWR Revalidation Intervals a useful comparison point.


Prerequisites

Before implementing the patterns below, confirm the following conditions hold in your project:

  • Apollo Client v3.x installed (@apollo/client ≥ 3.0). The cache.identify, cache.evict, cache.gc, and cache.modify APIs do not exist in v2.
  • Deterministic entity IDs: Every type that needs selective invalidation must carry a stable id or custom keyFields configuration. Without this, Apollo cannot construct a normalized cache key and falls back to storing data under the root query, which defeats field-level eviction.
  • TypeScript recommended. The cache.modify fields argument is fully typed when you enable InMemoryCacheConfig’s possibleTypes option.
  • Apollo DevTools browser extension installed for the inspection workflow in Step 1.

Step-by-step Implementation

Step 1 — Diagnose stale cache states with DevTools

Before writing any eviction logic, confirm that Apollo’s normalization is actually storing the entity you expect to evict.

  1. Open Apollo DevTools in the browser → Cache tab → enable Show Cache.
  2. Execute the stale query and locate the entry by __typename:id composite key (e.g., User:123).
  3. Run the mutation. In the Network tab, confirm the mutation response includes the id field that matches Apollo’s keyFields for that type.
  4. Run client.cache.extract() in the browser console and search for the composite key. If the key is absent or the fields are unchanged, the normalization is misconfigured — fix typePolicies.keyFields before proceeding.

Cache Behavior Analysis. cache.extract() returns a snapshot of the entire normalized store as a plain object. Each key is the composite cache ID Apollo computed at write time. If the mutation response omits the id field, Apollo cannot identify the object and writes it under a synthetic root key, bypassing the normalized entity entirely — eviction against User:123 will have no effect.


Step 2 — Configure typePolicies and keyArgs

Correct typePolicies is the foundation. Without it, cache.identify returns undefined and every eviction call silently no-ops.

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

const client = new ApolloClient({
  uri: '/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      // Identify User entities by their `id` field (the default, shown explicitly).
      User: {
        keyFields: ['id'],
      },
      // For types whose primary key is not `id`, name it here.
      TenantMembership: {
        keyFields: ['userId', 'tenantId'],
      },
      Query: {
        fields: {
          // keyArgs prevents Apollo from treating different filter combinations
          // as separate cache entries for the same logical query.
          users: {
            keyArgs: ['filter', ['role', 'status']],
            merge: (existing = [], incoming) => [...existing, ...incoming],
          },
        },
      },
    },
  }),
});

Cache Behavior Analysis. keyFields controls the composite cache key Apollo writes into InMemoryCache. With keyFields: ['id'], a User with id: "123" is always stored as User:123, regardless of which query fetched it. The keyArgs setting on Query.users means that users(filter: { role: "ADMIN" }) and users(filter: { role: "EDITOR" }) each get their own cache slot — evicting one does not disturb the other.

Trade-offs:

  • Compound keyFields (e.g., ['userId', 'tenantId']) increase the composite key length but are the only correct choice when no single field is unique.
  • Narrowing keyArgs too aggressively merges genuinely different result sets; widening it too broadly creates separate cache slots that will never share data even when the server returns the same records.

Step 3 — Evict the normalized entity after a mutation

Replace a growing refetchQueries array with a targeted update callback that evicts the specific entity.

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

const UPDATE_USER = gql`
  mutation UpdateUser($id: ID!, $name: String!) {
    updateUser(id: $id, name: $name) {
      id
      name
      email
    }
  }
`;

export function useUpdateUser() {
  return useMutation(UPDATE_USER, {
    update(cache, { data }) {
      if (!data?.updateUser) return;

      // cache.identify derives the composite key from __typename + keyFields.
      // Result: "User:123"
      const cacheId = cache.identify(data.updateUser);

      // Evict removes the entire normalized entry for this entity.
      cache.evict({ id: cacheId });

      // gc sweeps any objects now unreachable from root — e.g. field references
      // that pointed to the evicted User entry.
      cache.gc();
    },
  });
}

Cache Behavior Analysis. cache.evict({ id: 'User:123' }) deletes the User:123 slot from InMemoryCache’s data map. Every active query that reads through that slot detects a cache miss on the next render cycle and re-executes against the network. cache.gc() then removes any dangling Reference objects — for example, if a Post entity had a author field pointing to User:123, that reference becomes a ghost after eviction. Skipping cache.gc() does not cause incorrect data, but the store gradually accumulates unreachable objects that inflate memory usage.

Trade-offs:

  • Full entity eviction forces a loading state on every component subscribed to that entity. When you already have the complete new field values in the mutation response, prefer cache.modify (Step 4) to avoid the loading flash.
  • cache.evict without cache.gc() is safe but leaves orphaned references; call gc() in the same microtask unless profiling shows it is expensive for your store size.

Step 4 — Field-level rewrite with cache.modify

When the mutation response contains all updated fields, write them directly instead of evicting. The component re-renders with fresh data immediately — no loading state, no network round-trip.

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

const ARCHIVE_USER = gql`
  mutation ArchiveUser($id: ID!) {
    archiveUser(id: $id) {
      id
      status
      archivedAt
    }
  }
`;

export function useArchiveUser() {
  return useMutation(ARCHIVE_USER, {
    update(cache, { data }) {
      if (!data?.archiveUser) return;

      cache.modify({
        id: cache.identify(data.archiveUser),
        fields: {
          // Each field function receives the existing value as first argument.
          status: () => data.archiveUser.status,
          archivedAt: () => data.archiveUser.archivedAt,
        },
      });
    },
  });
}

Cache Behavior Analysis. cache.modify performs a surgical write directly into the normalized store without removing the entity. Apollo broadcasts a diff to every subscribed useQuery that reads the modified fields, triggering a synchronous re-render with the new data. No network request is issued because the entity still exists in the cache — only its field values changed. This is the preferred path when you can guarantee the mutation response includes every field the UI displays.

Trade-offs:

  • cache.modify cannot add new fields that were never fetched for an entity. If your query asks for lastLoginAt but the mutation response does not return it, you cannot write it via modify — you must either include it in the mutation selection set or follow up with an evict.
  • Modifying a field that is part of a paginated list (e.g., removing an archived user from users(filter: { status: ACTIVE })) requires an additional readField + filter step inside the fields handler, or a separate evict on the list’s root field.

Step 5 — Handle concurrent mutations with optimisticResponse

Rapid successive mutations — bulk status updates, autosaving form fields — can leave the UI in a partial state if writes race without an optimistic baseline.

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

const SAVE_DRAFT = gql`
  mutation SaveDraft($id: ID!, $content: String!) {
    saveDraft(id: $id, content: $content) {
      id
      status
      content
      updatedAt
    }
  }
`;

export function useSaveDraft() {
  return useMutation(SAVE_DRAFT, {
    // Apollo applies this immediately to the cache before the network request
    // completes. It tracks the write as "optimistic" so it can be reverted.
    optimisticResponse: ({ id, content }) => ({
      saveDraft: {
        __typename: 'Draft',
        id,
        status: 'SAVING',
        content,
        updatedAt: new Date().toISOString(),
      },
    }),
    update(cache, { data }) {
      if (!data?.saveDraft) return;
      cache.modify({
        id: cache.identify(data.saveDraft),
        fields: {
          status:    () => data.saveDraft.status,
          content:   () => data.saveDraft.content,
          updatedAt: () => data.saveDraft.updatedAt,
        },
      });
    },
    onError(_error, { variables }) {
      // Apollo automatically reverts the optimisticResponse write on failure.
      // Add any application-level side effects here (e.g. toast notification).
      console.warn(`Draft ${variables.id} save failed; cache reverted.`);
    },
  });
}

Cache Behavior Analysis. When optimisticResponse is present, Apollo immediately applies the predicted response to the normalized cache and re-renders all subscribed components. It also stores a rollback snapshot of the pre-mutation cache state. When the real server response arrives, Apollo replaces the optimistic entry with the confirmed data. If the mutation fails, Apollo applies the rollback snapshot in full, restoring every field to its pre-mutation value — including any fields modified in the update callback during the optimistic pass.

Trade-offs:

  • optimisticResponse must mirror the exact shape returned by the server, including __typename on every nested object. A shape mismatch causes Apollo to write the optimistic data under a different cache key, creating a ghost entity that survives the rollback.
  • For concurrent mutations on the same entity, the last server response to settle wins. If mutation ordering matters, serialize the calls using a queue and validate cache.extract() state between writes in your test suite.

SVG: Apollo Cache Invalidation Flow

The diagram below shows how a mutation write propagates through Apollo’s InMemoryCache — from the optimisticResponse application through to the final server-confirmed write or rollback.

Apollo Cache Invalidation Flow Sequence diagram showing a mutation flowing through Apollo InMemoryCache: optimisticResponse applied immediately, server response replaces it on success, or rollback snapshot restores prior state on failure. Component useMutation InMemoryCache GraphQL Server mutate(vars) optimisticResponse → write + snapshot broadcast (optimistic data) HTTP POST /graphql response 200 OK (server data) update() → cache.modify / evict broadcast (confirmed data) on error: error response apply rollback snapshot broadcast (pre-mutation state)

Edge Cases & Gotchas

1. cache.identify returns undefined for non-normalized objects

If a mutation response type lacks a keyFields entry in typePolicies, Apollo stores it as an embedded object rather than a normalized entity. cache.identify returns undefined, and any subsequent cache.evict call silently no-ops.

Resolution: Add the type to typePolicies with explicit keyFields. If the backend cannot guarantee a stable ID, use keyFields: false to force all instances to be stored by reference at their parent — then evict the parent entity instead.

// typePolicies: { SearchResult: { keyFields: false } }
// Evict the owning Query field instead:
cache.evict({ id: 'ROOT_QUERY', fieldName: 'searchResults', args: { query: term } });
cache.gc();

2. List fields are not automatically updated after entity eviction

Evicting User:123 removes the entity from the store but does not remove the Reference to it from any users(...) list field. On the next render, Apollo resolves the dangling reference and returns null for that list slot (with a console warning), rather than the clean filtered list you probably want.

Resolution: Either evict the root list field as well, or use cache.modify with a readField filter to splice the evicted reference out of the list.

cache.modify({
  id: 'ROOT_QUERY',
  fields: {
    users(existingRefs: Reference[], { readField }) {
      return existingRefs.filter(ref => readField('id', ref) !== deletedId);
    },
  },
});

3. optimisticResponse shape mismatch creates ghost entities

If optimisticResponse returns a nested object without __typename, Apollo stores it as an embedded object. When the real server response arrives with __typename present, Apollo writes a second, normalized entry — and the optimistic ghost persists until the next gc() sweep.

Resolution: Always include __typename on every object in optimisticResponse, mirroring the exact shape the server returns. Enable @apollo/client’s __DEV__ warnings — they surface shape mismatches during development before they reach production.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
UI remains stale after successful mutation update callback is absent, or cache.identify returns undefined because the type lacks keyFields. Log cache.identify(data.updateUser) — if undefined, add the type to typePolicies. Add an update callback that calls cache.evict or cache.modify.
Network waterfall on every mutation refetchQueries targets entire query names, forcing all subscribed queries to re-fetch even when the data is available in the mutation response. Replace refetchQueries with a targeted update callback using cache.modify. Use refetchQueries only for queries whose data cannot be derived from the mutation response.
Dangling null entries in list after entity deletion cache.evict removes the entity but not the Reference in parent list fields, leaving an unresolvable pointer. Follow the eviction with a cache.modify on the list field that filters out the stale reference using readField('id', ref) !== deletedId.

Frequently Asked Questions

How do I verify which cache entries are active in production without DevTools?

Inject JSON.stringify(client.cache.extract()) into your error-monitoring middleware on mutation settle. Filter the output by __typename and entity ID to map active normalization keys. Keep this behind a feature flag or debug header — the full extract can be several megabytes on data-heavy pages.

Can targeted eviction replace refetchQueries entirely?

For most use cases, yes. cache.evict + cache.gc() and cache.modify provide declarative, entity-scoped invalidation that avoids the network entirely when the mutation response already contains the new field values. Reserve refetchQueries for cases where the mutation affects data your client does not yet hold — for example, a mutation that increments a server-side aggregate counter that was never included in any prior response.

What happens to cached data if eviction fires but the subsequent network fetch fails?

The entity is permanently removed from the local cache. Apollo issues a network fetch on the next read, shows a loading state, and then surfaces the error via useQuery’s error field. Configure errorPolicy: 'all' on the query to receive both data and errors simultaneously if the server returns a partial response. Implement a fallback UI state to avoid blocking user interaction while the re-fetch is in flight.