Apollo InMemoryCache vs RTK Query Normalization

Two of the most widely deployed server-state libraries take opposite positions on normalization. Apollo Client v3 normalizes automatically: any object carrying a __typename and an id (or a custom key) is split out of its response and stored once in a flat InMemoryCache, so every query that mentions that entity shares one record. RTK Query does the exact opposite — it caches each endpoint result as an opaque blob and never merges two responses that share an ID. Understanding this single divergence determines how you design entity mapping strategies for your app, and it interacts directly with how you build stable query keys for React Query if you later mix libraries. This page compares the two models on developer ergonomics, mutation-driven cache updates, and where each one earns its place.

The common mistake is to assume both libraries “cache your data” in the same shape. They do not. One stores a graph of deduplicated entities; the other stores a dictionary of request results. Everything downstream — mutation propagation, list consistency, memory footprint — follows from that.

The Core Divergence

Apollo’s InMemoryCache performs identity-based normalization. When a response arrives, Apollo walks the object tree, and for every node with a __typename and id it writes a record at the key Typename:id and replaces the inline object with a Reference. RTK Query performs request-based caching: the result is stored whole under a cache key derived from endpointName plus the serialized query argument. There is no entity extraction, no reference substitution, and no cross-request deduplication unless you build it.

  • Apollo: one entity ID → one store slot, shared by all queries. This is schema-driven normalization applied automatically from the GraphQL type system.
  • RTK Query: one request → one cache entry. Two endpoints returning the same user store two independent copies.

Apollo normalized store versus RTK Query request cache Left side shows two Apollo queries both pointing by reference to a single User:7 record. Right side shows two RTK Query endpoints each holding an independent full copy of user 7, which drift apart after a mutation. Apollo InMemoryCache RTK Query cache GetProfile → User:7 ref GetAuthors → User:7 ref User:7 name: "Ada" one shared slot Mutation patches User:7 both queries re-render ✓ automatic · ✓ consistent getProfile(7) User 7 copy A name: "Ada" getAuthors() User 7 copy B name: "Ada" Mutation renames User 7 copy A: "Ada Lovelace" copy B still "Ada" until tag invalidation refetches ⚠ manual sync required
Apollo dedupes User:7 to one slot so a mutation touches every consumer; RTK Query holds two copies that drift until a tag invalidation refetches the stale one.

Developer Ergonomics

Apollo: normalization is free, customization is a config surface

With Apollo you write a query, and normalization happens with zero extra code as long as every entity exposes id and __typename. The cost surfaces when your entities do not fit that assumption: a type keyed by something other than id, a list field that needs pagination merging, or a computed field. All of these are configured through typePolicies.

// apollo/cache.ts
import { InMemoryCache } from '@apollo/client';

export const cache = new InMemoryCache({
  typePolicies: {
    // Entity whose primary key is not "id"
    Organisation: {
      keyFields: ['slug'],
    },
    User: {
      fields: {
        // Client-derived field computed from normalized scalars
        fullName: {
          read(_, { readField }) {
            return `${readField('firstName')} ${readField('lastName')}`;
          },
        },
      },
    },
  },
});

Cache Behavior Analysis. keyFields: ['slug'] tells the InMemoryCache to store each Organisation under Organisation:{"slug":"acme"} instead of the default Organisation:id, so any query returning that org by slug hits the same store slot. The read function for fullName runs at read time against normalized scalars — it is never persisted, so firstName/lastName remain the single source of truth and the derived field can never drift.

  • Trade-offs: keyFields must be present in every selection set that fetches the type, or Apollo cannot normalize and logs a cache-miss warning. read functions run on every field access, so keep them cheap. There is no staleTime equivalent — Apollo relies on fetchPolicy (cache-first, cache-and-network) rather than a time-based freshness window.

RTK Query: caching is free, normalization is your code

RTK Query gives you request caching, tag invalidation, and generated hooks with almost no configuration. What it does not give you is entity deduplication. If you want a normalized store you build it explicitly with createEntityAdapter inside transformResponse.

// api/usersApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { createEntityAdapter, EntityState } from '@reduxjs/toolkit';

interface User {
  id: string;
  name: string;
  email: string;
}

const usersAdapter = createEntityAdapter<User>();

export const usersApi = createApi({
  reducerPath: 'usersApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User'],
  endpoints: (build) => ({
    getUsers: build.query<EntityState<User, string>, void>({
      query: () => 'users',
      // Manual normalization: fold the array into { ids, entities }
      transformResponse: (raw: User[]) =>
        usersAdapter.setAll(usersAdapter.getInitialState(), raw),
      providesTags: (result) =>
        result
          ? [
              ...result.ids.map((id) => ({ type: 'User' as const, id })),
              { type: 'User' as const, id: 'LIST' },
            ]
          : [{ type: 'User' as const, id: 'LIST' }],
    }),
  }),
});

Cache Behavior Analysis. transformResponse runs once per fetch and reshapes the array into an EntityState with ids and entities maps, giving you O(1) lookup inside this one cache entry. Critically, this normalization is local to getUsers — a separate getUser(id) endpoint still holds its own copy. The providesTags entries are what make cross-endpoint consistency possible: they are the handle a mutation’s invalidatesTags grabs to force a refetch.

  • Trade-offs: createEntityAdapter normalizes within an endpoint, not across endpoints — there is no global entity store unless you engineer one. Deduplication across queries is achieved through providesTags/invalidatesTags invalidation, not shared references. Use serializeQueryArgs and merge on build.query when you need infinite-scroll accumulation, since RTK Query otherwise replaces the cached entry on each fetch.

Cache Updates After Mutations

This is where the two models feel most different in day-to-day work.

Apollo: scalar edits propagate automatically

If a mutation returns an entity with a __typename:id that already exists in the cache, Apollo merges the returned fields into that store slot and every active query re-renders. You write nothing.

// Renaming a user — no update logic needed for scalar fields
const [renameUser] = useMutation(RENAME_USER);

await renameUser({
  variables: { id: '7', name: 'Ada Lovelace' },
  // Response includes { __typename: 'User', id: '7', name: 'Ada Lovelace' }
});

The one thing Apollo cannot infer is list membership: adding or removing an item from a cached list requires cache.modify or update, because the cache cannot know which query lists the new entity belongs to.

const [addUser] = useMutation(ADD_USER, {
  update(cache, { data }) {
    cache.modify({
      fields: {
        users(existingRefs = [], { toReference }) {
          const ref = toReference(data.addUser);
          return ref ? [...existingRefs, ref] : existingRefs;
        },
      },
    });
  },
});

Cache Behavior Analysis. cache.modify reaches into the users field and appends a Reference produced by toReference, so the new entity is stored once and the list simply gains a pointer. Because it is a reference and not a copy, a later scalar edit to that same user still propagates to this list automatically — the manual step is only the membership change, never the field sync.

RTK Query: choose invalidation or manual patching

RTK Query mutations do not patch related caches by value. You pick one of two strategies. Tag invalidation is declarative and refetches from the server:

renameUser: build.mutation<User, { id: string; name: string }>({
  query: ({ id, ...patch }) => ({ url: `users/${id}`, method: 'PATCH', body: patch }),
  // Any query providing User:id refetches
  invalidatesTags: (_res, _err, { id }) => [{ type: 'User', id }],
}),

Optimistic patching mutates the cache in place with no refetch:

renameUser: build.mutation<User, { id: string; name: string }>({
  query: ({ id, ...patch }) => ({ url: `users/${id}`, method: 'PATCH', body: patch }),
  async onQueryStarted({ id, name }, { dispatch, queryFulfilled }) {
    const patchResult = dispatch(
      usersApi.util.updateQueryData('getUsers', undefined, (draft) => {
        const entity = draft.entities[id];
        if (entity) entity.name = name;
      }),
    );
    try {
      await queryFulfilled;
    } catch {
      patchResult.undo(); // roll back on failure
    }
  },
}),

Cache Behavior Analysis. invalidatesTags marks every cache entry that declared { type: 'User', id } in its providesTags as stale and triggers a background refetch — correct but network-bound. updateQueryData instead produces an Immer draft of one specific cache entry and applies the edit locally with zero network cost; patchResult.undo() restores the pre-mutation snapshot if the request rejects. Note you must patch each endpoint that holds a copy of that user — there is no single slot to update.

  • Trade-offs: tag invalidation is simplest but costs a round trip per invalidated tag; scope tags with { type, id } rather than blanket 'User' to avoid refetching unrelated lists. updateQueryData avoids the round trip but you own consistency across every endpoint copy. Apollo’s automatic scalar propagation removes that bookkeeping entirely but only for GraphQL responses carrying stable keys.

When Each One Fits

Choose Apollo InMemoryCache when you have a GraphQL API with a well-typed schema, deeply relational data, and many overlapping queries that read the same entities — the automatic deduplication and reference sharing pay off enormously as the graph grows. Choose RTK Query when your backend is REST/RPC, when you already run a Redux store and want server-cache state under the same devtools and middleware, or when your data is mostly flat lists where tag invalidation is simpler to reason about than field policies. Mixed apps are common: RTK Query for REST resources, plus a normalized adapter only for the few endpoints where cross-query consistency actually hurts.

Common Pitfalls and Resolutions

Observable Issue Root Cause Diagnostic Resolution
Two RTK Query screens show different values for the same user after an edit RTK Query stores an independent copy per endpoint; a mutation updated one entry and left the other stale Add { type: 'User', id } to both endpoints’ providesTags and invalidatesTags on the mutation, or patch every copy via updateQueryData
Apollo logs “Missing field ‘id’ while writing result” and skips normalization The selection set omitted id/keyFields, so Apollo cannot compute a store key and caches the object inline Add id (and any custom keyFields) to every query and fragment selecting that type; the field must be present in the response, not just the schema
Apollo mutation updates a detail view but the list still shows the old title The mutation changed a scalar (auto-propagated) but the item was newly added/removed — list membership is not inferred Use cache.modify with toReference to add, or cache.evict + cache.gc to remove, then let reference sharing keep scalars in sync
RTK Query infinite list replaces prior pages instead of appending No serializeQueryArgs/merge configured, so each fetch overwrites the single cache entry for that endpoint Add serializeQueryArgs to strip the page arg and a merge function to concatenate, mirroring Apollo’s field merge

Frequently Asked Questions

Does RTK Query normalize entities automatically like Apollo does?

No. RTK Query caches each endpoint result as an opaque value keyed by endpoint name plus serialized arguments. It never merges two responses that share an entity ID into a single record. To get normalized storage you transform the response yourself with createEntityAdapter inside transformResponse, and even then the normalization is local to that one endpoint. Apollo splits every object with a __typename and id into its own flat store slot with no extra code, which is why cross-query consistency is automatic there and manual here.

If Apollo normalizes automatically, why choose RTK Query at all?

Apollo’s automatic normalization assumes a GraphQL schema with stable __typename and id fields. For REST or RPC backends, RTK Query’s tag-based invalidation is simpler and needs no schema. RTK Query also lives inside a Redux store, so your client state and server-cache state share one devtools timeline, one middleware chain, and one serialization boundary. The trade-off is that relationship stitching and cross-query propagation become code you write rather than behavior you get.

How do cache updates after a mutation differ between the two?

In Apollo, a mutation returning an object with an existing __typename:id patches that store slot, and every query referencing it re-renders automatically — you only write update logic for list membership changes. In RTK Query, mutations do not update related caches by value: you either invalidatesTags to refetch, or patch each affected entry with updateQueryData inside onQueryStarted. Apollo is automatic for scalar edits; RTK Query is explicit but gives you precise control over exactly what refetches and when.