Why the Apollo Cache Keeps Surprising You
Shopify’s migration from Apollo GraphQL client 2 to client 3 surfaced a familiar pattern: many bugs traced back to how developers—ourselves included—use the cache. The cache isn’t a magic storage box where your data just lives untouched. It’s an InMemoryCache that transforms and reshapes your query results in specific ways. Understanding the life cycle of cached objects—how they’re fetched, stored, merged, and eventually removed—turns cache-related bugs into something predictable. Let’s walk through that life cycle.
The problem hides in how the cache normalizes data. Queried objects can look identical at the type level, but the cache’s handling of their fields determines whether they’re treated as the same entity or as distinct entries—and that’s where things quietly go wrong. More on that in a moment.
Where Cached Data Comes From
The InMemoryCache holds data only for the browser session it was created in. Close or reload the tab, and it’s gone—the cache doesn’t persist to local storage or any other cross-session medium. It also stores a representation of your data, not the data itself, which is a distinction that matters once normalization enters the picture.
Fetch policies decide whether Apollo asks the network, the cache, or both. The default for useQuery is cache-first: Apollo serves the request entirely from the cache if every requested field is present; otherwise it goes to the network, writes the fresh data into the cache, and returns it. The other policies—network-first, cache-only, network-only, and the rest—are variations of that same idea. Picking the wrong one for a given operation is a common source of bugs that look like “the cache is stale” but are really “I told Apollo to ignore the cache.”
How the Cache Stores Your Query Results
Normalization is the process that turns your GraphQL response into the cache’s internal structure. It happens in three steps.
First, the cache splits the queried data into individual objects. It uses ID fields as signals for where to break things apart. Second, each object gets a globally unique cache identifier, typically built by combining the object’s __typename with its id. That “typically” matters: your schema may have types without an id field, or with some other field that’s the real unique key. For those, the keyFields API lets you define which fields the cache should use instead. A good cache key is stable and reproducible so the cache can look up the same object consistently every time.
Third, the cache takes those broken-out objects and stores them in a flattened structure—essentially a hash map. That shape gives the fastest possible lookup. It also means duplicate objects end up in the same slot, which keeps the cache as compact as it can be. The downside: if two objects end up with the same cache key, the cache treats them as one, whether or not that’s what you intended.
Automatic Updates: The Merge, and Its Limits
When new query results or mutation responses arrive, the cache calculates the new objects’ IDs. If an object with that ID already exists, the cache merges the incoming fields into the stored object, preferring the incoming values. If not, it adds a new entry. Then Apollo broadcasts an update to every query that previously read that object, and those queries re-render with fresh data.
That merge-and-broadcast is what makes UI updates feel automatic. It works in two situations. First, when you edit a single entity and your mutation response returns that same entity type, including its ID and at least the field you changed—for example, favoriting a product and returning the product with its favorited status. Second, when you edit a collection and your mutation returns the entire collection of the same type, every object included, with its IDs.
Outside those two cases, the cache won’t infer what you mean. These are the situations where automatic updates fail:
- Response data is unrelated to the change you want. If favoriting a product should also bump a “number of favorites” counter that wasn’t in the response, you need an
updatefunction or a refetch on that counter query. - You changed multiple entities but didn’t return the full set. The cache can’t figure out whether missing objects were deleted or just omitted, so it leaves the list alone.
- The order of the returned collection differs from the cached one. The cache doesn’t read meaning into sequence, so reordering a list requires an explicit
updatefunction. - The response added or removed an item from the collection. The cache can’t tell that an item vanished or appeared unless the whole list comes back—so unfavoriting a product while viewing a favorites list won’t remove it without manual intervention.
Each of these comes down to one principle: the cache is mechanical, not semantic. If you didn’t explicitly describe a side effect, it won’t happen.
The Query That Broke, Explained
Now back to the failing query. The productMetas and metaData objects return the same type, MetaData—and in this case they share the same ID. During normalization, the cache collapsed them into one cache entry. Then it tried to normalize the nested values object from both into a single MetaData.values. But one values object has an id field while the other returns only a slug. Without a matching ID, the cache can’t merge the two values objects as one, so the second one’s data isn’t represented in the normalized MetaData.values object. Nothing is lost from the server’s perspective—the data just didn’t end up where the query expected it to be. The fix is to return the id on the second values object so the cache can recognize both as the same entity and merge them correctly.
The Cache Doesn’t Clean Itself
Without intervention, cached objects stay in the InMemoryCache for the life of the session, getting overwritten and extended as you query and mutate. That’s fine for short-lived pages. But in a long-lived application that continuously pulls new data—think a map app where you pan across points of interest—the points you left behind still occupy memory. Over time, cache growth drags down responsiveness.
The remedy is pruning strategies that evict unused data, either by configuring garbage collection rules or by actively removing entries that are no longer relevant. Exactly which approach fits depends on your app’s data access patterns, but the key takeaway is the same: the cache only persists what you let it.
Evicting Unreachable Data from the Cache
Apollo Client 3 ships with a garbage collector that can help you clean up after yourself. The method is straightforward: calling cache.gc() removes unreachable items from the cache and returns a list of IDs for the removed objects. The important caveat is that garbage collection is not automatic — it’s up to you to invoke the method when appropriate.
To understand what the garbage collector does, it helps to see how unreachable objects get created in the first place. Consider a sample app (the code is available here) that renders a pixel-art representation of a Pikachu and prints the cache size next to it. The counter displays the number of top-level keys in the normalized cache, giving a rough idea of total cache size.
Behind the frontend is a GraphQL server with a few mutations. The pixels arrive through a PixelImage query, and a mutation lets you change the body pixels’ colors to get the shiny version of Pikachu. When you fire that mutation and re-query the data, the cache size jumps significantly:
Notice what happens: the cache roughly doubles in size. The pixel objects now have unique identifiers that changed when the colors changed, so the new data replaces the old data in the query results. But the old pixel objects are not deleted — they are still in the cache, just unreachable. Any time you re-query data that now comes back with different identifiers, you orphan the old objects. This is why garbage collection may be necessary.
The diagram below shows the garbage collector traversing the cache tree. On the left are the new, reachable objects; the collector can walk from the Root object through each reference to determine what is accessible. On the right is the original query, which is no longer reachable from the root. That is how the collector decides what to remove from memory.
Lifecycle Thinking
Garbage collection is the final step in an object’s lifecycle in the cache. Viewing any field requested from your GraphQL server as part of an object that lives and updates in the cache over time clarifies many interactions in your application. For instance, when you query for things with IDs, you can anticipate automatic updates for those objects when you mutate states like pinning or favoriting. Designing components around GraphQL data updates means state changes are determined purely by data values, avoiding duplication of server-side data into client-side state management — a step that often adds complexity.
Understanding how caching layers work also affects how you query for objects. By taking advantage of the free updates the cache provides, you can build more efficient frontend applications. The demo applications linked below let you watch the cache update in real time as you perform different interactions, helping you build a mental model of frontend development with Apollo Client.
Demo Applications
Fork both projects to try them out. Once the server project initializes, take the displayed “url” and update the frontend’s ApolloClient configuration with it so you can run the queries.
- Client: pixel-image-client-call
- Server: pixelated-image-server



