Flattening Deeply Nested GraphQL Responses
GraphQL’s composable query model makes it easy to fetch richly related data in a single round trip, but it also encourages response shapes where the same entity appears at multiple nesting levels. When Apollo’s InMemoryCache cannot resolve stable identity for each node, it stores duplicate copies of the same record rather than a single normalized entry. The observable effects are phantom re-renders (React sees new object references even though the data did not change), stale relationship pointers after mutations, and query key collisions where structurally similar but semantically distinct queries overwrite each other’s cache slots.
This recipe is part of the Nested Data Flattening Techniques guide, which covers iterative payload traversal and framework adapter configuration for generic JSON APIs. If your data also arrives via cursor-based pagination, the companion page on merging paginated lists without duplicates addresses the deduplication logic you need alongside the recipes here.
Diagnostic Checklist
Before writing transformation code, confirm the problem root cause:
client.cache.extract()in the browser console shows the same entity stored under multiple cache keys (e.g.,User:42andUser:42appearing as separate{"id": 42, ...}objects with no shared__ref).- The React Profiler logs re-renders of unchanged subtrees immediately after a partial mutation.
__typenameis present on root nodes but absent on nested array items in the network response.- Apollo DevTools shows
ROOT_QUERYstoring entire nested objects inline rather than{ "__ref": "SomeType:id" }pointers. - Two different queries that both fetch
User:42result in separate cache entries that diverge after an optimistic update.
How Normalization Fails for Nested GraphQL Payloads
The diagram below shows what happens when __typename or id is missing at a nested level: Apollo stores the node inline inside the parent record instead of writing it to its own normalized slot and replacing it with a __ref pointer.
Step 1 — Ensure __typename and id on Every Selection Set
Apollo’s automatic normalization only fires when both __typename and a key field (default id) exist on a node. The most common cause of inline storage is queries that omit __typename on deeply nested objects, or GraphQL schemas that expose anonymous connection edges.
1a. Add __typename to every fragment or inline selection set that represents a reusable entity:
query PostWithAuthor($postId: ID!) {
post(id: $postId) {
__typename
id
title
author {
__typename # required — without this Apollo stores author inline in Post:1
id
name
avatar {
__typename
id
url
}
}
tags {
__typename
id
label
}
}
}
Cache Behavior Analysis. When Apollo receives this response, it walks every object node in the payload. For each node where __typename and id are both present, it writes the node to a dedicated slot in InMemoryCache (User:42, Tag:sports, etc.) and replaces that node in the parent with { "__ref": "User:42" }. Nodes that lack either field are stored inline inside the parent record — this is the root cause of duplicate storage.
1b. If you use auto-persisted queries or a codegen tool, enable the addTypename option so the client injects __typename automatically rather than requiring hand-edited queries:
import { ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: '/graphql',
cache: new InMemoryCache(),
// addTypename defaults to true — set it explicitly so it survives tree-shaking config audits
defaultOptions: {
watchQuery: { fetchPolicy: 'cache-and-network' },
},
});
Cache Behavior Analysis. addTypename: true (the default) causes Apollo Client to inject __typename into every selection set at the network request layer before the query leaves the browser. This means even legacy query strings that omit __typename will receive normalized storage without requiring a query rewrite.
Step 2 — Configure keyFields for Non-Standard Identifiers
When entity types use a field name other than id as their stable identifier (e.g., uuid, slug, sku), Apollo falls back to inline storage because it cannot locate the default key. Register explicit keyFields per type:
import { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
// Entities with a standard "id" field — explicit for auditability
Post: { keyFields: ['id'] },
Tag: { keyFields: ['id'] },
// Entity that uses a non-standard identifier
ProductVariant: { keyFields: ['sku', 'warehouseId'] },
// Paginated connection edge — no stable ID; store inline but deduplicate in merge
PostEdge: { keyFields: false },
Query: {
fields: {
posts: {
// Append-only merge that deduplicates on __ref pointer
merge(existing: readonly any[] = [], incoming: readonly any[]) {
const seen = new Set(existing.map((e) => e.__ref));
const novel = incoming.filter((item) => !seen.has(item.__ref));
return [...existing, ...novel];
},
},
},
},
},
});
Cache Behavior Analysis. keyFields: ['sku', 'warehouseId'] tells Apollo to compute the cache key by concatenating the values of both fields (ProductVariant:{"sku":"ABC","warehouseId":"WH1"}). This guarantees a unique, stable slot for each variant even without a surrogate id. keyFields: false on PostEdge opts that type out of normalization entirely, preventing spurious PostEdge:undefined entries when edge nodes lack identity fields.
Trade-offs. Composite keyFields increase the verbosity of cache keys and complicate cache.modify calls, which must reconstruct the exact key object. If sku or warehouseId can change (e.g., after a warehouse transfer), the old cache entry becomes orphaned — call cache.evict({ id: cache.identify(oldVariant) }) immediately after the mutation response to sweep the stale slot.
Step 3 — Write a Recursive Flattening Utility for Pre-Normalization Pipelines
For teams that pre-process GraphQL responses before handing them to Apollo (e.g., in a service worker, a BFF layer, or a link middleware), a recursive flattening pass resolves entity boundaries deterministically and outputs a flat entity map compatible with cache.restore().
type EntityMap = Record<string, Record<string, unknown>>;
export function flattenGraphQLResponse(
node: Record<string, unknown>,
entityMap: EntityMap = {},
visited = new Set<string>(),
): { __ref: string } | Record<string, unknown> {
if (!node || typeof node !== 'object') return node as Record<string, unknown>;
const typename = node['__typename'] as string | undefined;
const id = (node['id'] ?? node['uuid'] ?? node['slug']) as string | number | undefined;
// Only normalize nodes that carry stable identity
if (!typename || id === undefined) {
// Recurse into children but store this node inline
const inlined: Record<string, unknown> = {};
for (const key of Object.keys(node)) {
const val = node[key];
inlined[key] = Array.isArray(val)
? val.map((item) =>
item && typeof item === 'object'
? flattenGraphQLResponse(item as Record<string, unknown>, entityMap, visited)
: item,
)
: val && typeof val === 'object'
? flattenGraphQLResponse(val as Record<string, unknown>, entityMap, visited)
: val;
}
return inlined;
}
const cacheKey = `${typename}:${id}`;
// Break cycles — return a pointer without recursing again
if (visited.has(cacheKey)) return { __ref: cacheKey };
visited.add(cacheKey);
const flat: Record<string, unknown> = {};
for (const key of Object.keys(node)) {
const val = node[key];
flat[key] = Array.isArray(val)
? val.map((item) =>
item && typeof item === 'object'
? flattenGraphQLResponse(item as Record<string, unknown>, entityMap, visited)
: item,
)
: val && typeof val === 'object'
? flattenGraphQLResponse(val as Record<string, unknown>, entityMap, visited)
: val;
}
entityMap[cacheKey] = flat;
return { __ref: cacheKey };
}
Usage:
const entityMap: EntityMap = {};
const rootRef = flattenGraphQLResponse(rawGraphQLData.post, entityMap);
// Feed into Apollo's cache for server-side hydration or testing
client.cache.restore(
Object.fromEntries(
Object.entries(entityMap).map(([key, val]) => [key, val])
)
);
Cache Behavior Analysis. The utility mirrors what Apollo’s InMemoryCache does internally during a writeQuery call, but exposes the intermediate entity map as a plain JavaScript object. This is particularly useful in SSR pipelines where you want to call cache.restore() on the client with a pre-built normalized snapshot rather than re-fetching. The visited Set prevents infinite traversal in bidirectional schemas (e.g., Post → Author → posts → Post).
Edge Cases & Gotchas
Gotcha 1 — Polymorphic Union Types Store Under the Wrong Key
When a GraphQL field returns a union (SearchResult = Post | User | Product), Apollo resolves the cache key from the concrete __typename on each returned item. If your typePolicies only define keyFields for the abstract union name, they are ignored — the concrete type names must each be registered:
const cache = new InMemoryCache({
possibleTypes: {
SearchResult: ['Post', 'User', 'Product'],
},
typePolicies: {
// Wrong: 'SearchResult' is never the actual __typename value
// SearchResult: { keyFields: ['id'] },
// Correct: register each concrete member
Post: { keyFields: ['id'] },
User: { keyFields: ['id'] },
Product: { keyFields: ['id', '__typename'] },
},
});
The possibleTypes map tells Apollo which concrete types implement an interface or belong to a union, enabling fragment matching and type policy resolution.
Gotcha 2 — Pagination Cursors Recreate Arrays and Break Memoization
Cursor-based pagination typically returns a new array reference on every page fetch, which defeats React.memo and useMemo on list components even when no individual entity changed. The merge function in Step 2 addresses this, but only if fetchMore passes the same variables shape on every call. If cursor variables differ in key order between fetches, Apollo treats them as distinct field arguments and creates a parallel cache entry rather than merging:
// Consistent variable shape across all fetchMore calls
fetchMore({
variables: { cursor: nextCursor, limit: 20 },
// Do NOT spread additional fields unless they are stable
});
Register a keyArgs config on the paginated field to tell Apollo which variables distinguish separate lists versus which variables are pagination cursors that should merge into one:
Query: {
fields: {
posts: {
keyArgs: ['filter', 'sort'], // these create separate cache entries
// 'cursor' and 'limit' are intentionally omitted — they are pagination args
merge(existing: readonly any[] = [], incoming: readonly any[]) {
const seen = new Set(existing.map((e) => e.__ref));
return [...existing, ...incoming.filter((i) => !seen.has(i.__ref))];
},
},
},
},
Gotcha 3 — cache.modify Requires the Exact Composite Cache Key
When keyFields uses multiple fields, cache.modify must receive the same composite object — passing just the id will silently do nothing:
// Incorrect — Apollo cannot locate ProductVariant by id alone when keyFields is composite
cache.modify({
id: cache.identify({ __typename: 'ProductVariant', id: 'ABC' }),
fields: { stock: () => 0 },
});
// Correct — provide every field listed in keyFields
cache.modify({
id: cache.identify({ __typename: 'ProductVariant', sku: 'ABC', warehouseId: 'WH1' }),
fields: { stock: () => 0 },
});
Use cache.identify(entity) rather than constructing the cache key string manually — it reads your typePolicies and formats the composite key correctly.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Stale UI after partial nested mutation | __typename or id absent on the mutated nested type; Apollo stores it inline and cannot locate it by cache key for writeFragment or cache.modify |
Add __typename and the key field to every selection set; verify with client.cache.extract() that the entity has its own top-level slot |
| Excessive re-renders on every page of infinite scroll | Missing keyArgs config causes Apollo to create a new cache field per cursor value; each fetchMore replaces rather than merges the array |
Add keyArgs: ['filter', 'sort'] to exclude pagination cursors from field identity; confirm in Apollo DevTools that a single field accumulates pages |
cache.evict leaves orphaned child objects |
Evicting a parent does not cascade to children stored in their own normalized slots | Call cache.gc() after cache.evict to sweep unreachable normalized entries; enable retainUnused: false if your Apollo version supports it |
Frequently Asked Questions
How do I handle GraphQL responses where entities have no unique ID?
Generate deterministic composite keys using __typename combined with stable business-domain fields (slug, externalId, sku). Register them as keyFields: ['__typename', 'slug'] in your InMemoryCache type policy. Validate that none of those fields are mutable across entity updates — a field that changes value after creation will generate a new cache key, leaving the old slot orphaned and requiring explicit cache.evict.
Will flattening break optimistic updates?
Flat entity maps make optimistic updates more reliable, not less. cache.modify targets a single normalized slot by its cache key, so an optimistic write touches exactly the entity you intend without needing to navigate a nested tree. If the server response diverges from the optimistic value, Apollo’s reconciliation replaces only the affected entity — the rest of the cache is unaffected. Compare this to nested storage, where an optimistic write must reconstruct the full nesting path or risk writing to the wrong inline object.
Can I safely normalize circular GraphQL schemas?
Yes. Both the recursive utility in Step 3 and Apollo’s own InMemoryCache handle bidirectional relationships by replacing already-visited entities with { "__ref": "Type:id" } pointers rather than recursing into them again. The critical requirement is that every entity in the cycle carries __typename and a stable id — without these, Apollo cannot detect the cycle and will store duplicate inline objects at each depth level.
Related
- Nested Data Flattening Techniques — covers iterative traversal algorithms and adapter configuration for generic JSON payloads, including the
WeakSet-based cycle guard and React Queryselectintegration this page builds on. - Merging Paginated Lists Without Duplicates — companion recipe for deduplicating entity arrays during infinite scroll and cursor pagination, directly relevant to the
keyArgs/ merge function patterns above. - Relationship Stitching in Cache — explains how to reconstruct parent-to-child and inverse relationship maps from the flat entity maps produced by the flattening pipeline here.
- Data Normalization & Query Key Design — the parent reference covering normalization strategy selection, entity mapping, and query key derivation across React Query, Apollo, and RTK Query.