Handling Circular References in Cache
Circular references trigger TypeError: Converting circular structure to JSON during cache persistence, RangeError: Maximum call stack size exceeded in custom reducers, and silent memory leaks in long-lived SPAs — all originating from the same root cause: a stored object graph that contains a back-edge pointing to an ancestor node. This page shows you how to identify the cycle, guard against it during serialization, and prevent it from forming in the first place through normalization.
This technique belongs to the Reference vs Value Storage Models strategy space. If you are also dealing with deeply nested payloads that are not themselves circular, the companion page on flattening deeply nested GraphQL responses covers a related set of flattening techniques.
Diagnostic Checklist
Before writing any fix, confirm you are facing a circular reference rather than an unrelated serialization failure.
TypeError: Converting circular structure to JSONappears during a cache write,localStorage.setItem, or SSR__NEXT_DATA__injection.RangeError: Maximum call stack size exceededfires inside a custom reducer, a deep-clone utility, or a recursive selector.- Chrome DevTools Memory tab shows progressive heap growth between identical interactions — detached nodes retain back-references to parent state slices.
- Wrapping the suspect payload in
structuredClone()succeeds butJSON.stringify()throws — this is diagnostic:structuredClonetolerates cycles while JSON serialization does not. - A parent–child entity pair (comment/post, task/project, employee/department) was loaded from an API that embeds each side of the relationship inside the other.
Step-by-Step Implementation
Step 1 — Isolate the cycle with a WeakSet probe
Run this in your browser console or inside a test before touching production code.
function findCircular(obj: unknown): boolean {
const seen = new WeakSet();
function walk(val: unknown): boolean {
if (typeof val !== 'object' || val === null) return false;
if (seen.has(val)) return true; // cycle detected
seen.add(val);
return Object.values(val as Record<string, unknown>).some(walk);
}
return walk(obj);
}
// Usage: throw if the query result would crash JSON.stringify
queryClient.setQueryData(['thread', threadId], (old) => {
const next = mergeThread(old, incomingPayload);
if (findCircular(next)) {
console.error('[cache] circular reference in thread payload', next);
return old; // keep previous safe state
}
return next;
});
Cache Behavior Analysis. queryClient.setQueryData writes synchronously into React Query’s in-memory cache without triggering a network request. By returning old on cycle detection you leave the previous stale entry intact. React Query’s structuralSharing option (enabled by default) then compares the returned value against the existing entry; because the reference is identical, no subscriber re-renders, so the UI is unaffected while you investigate.
Step 2 — Guard serialization with a cycle-aware replacer
When you must persist to localStorage, sessionStorage, or an SSR serialization boundary, use a WeakSet-backed replacer:
function serializeWithCycleGuard(obj: unknown): string {
const seen = new WeakSet();
return JSON.stringify(obj, (_key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
// Replace the back-edge with a sentinel — never omit silently
return { __circular: true };
}
seen.add(value);
}
return value;
});
}
// Paired reviver: strip sentinels on hydration
function hydrateWithGuard<T>(json: string): T {
return JSON.parse(json, (_key, value) => {
if (value && typeof value === 'object' && value.__circular) return undefined;
return value;
});
}
Cache Behavior Analysis. The replacer function is called once per node in the object graph. WeakSet.has() runs in amortised O(1) and does not prevent garbage collection of the tracked objects, keeping memory pressure low even for large payloads. Returning { __circular: true } rather than undefined preserves the key in the serialized output, allowing the paired reviver to reconstruct a consistent (though incomplete) shape on hydration. Returning undefined would silently drop the key, causing downstream selector misses that are harder to debug.
Step 3 — Prevent cycles at the normalization boundary
The most robust fix is architectural: normalize the payload to an ID-keyed flat map before it reaches the cache, so a back-edge becomes a string ID rather than a live object reference. This aligns with the reference vs value storage model principle that shared entities live in exactly one place.
interface CacheNode {
id: string;
data: Record<string, unknown>;
childIds: string[];
parentId: string | null;
}
type NormalizedGraph = Map<string, CacheNode>;
function normalizeGraph(root: {
id: string;
children?: Array<{ id: string; children?: unknown[] }>;
[key: string]: unknown;
}): NormalizedGraph {
const cache: NormalizedGraph = new Map();
const seen = new WeakSet<object>();
function traverse(
node: { id: string; children?: Array<{ id: string; children?: unknown[] }>; [key: string]: unknown },
parentId: string | null
): void {
// Guard: skip already-visited nodes (cycle break) and repeated IDs
if (seen.has(node) || cache.has(node.id)) return;
seen.add(node);
const { id, children = [], ...rest } = node;
cache.set(id, {
id,
data: rest,
childIds: children.map((c) => c.id),
parentId,
});
for (const child of children) {
traverse(child as typeof node, id);
}
}
traverse(root, null);
return cache;
}
// Wire into React Query
const { data: graph } = useQuery({
queryKey: ['graph', rootId],
queryFn: () => fetchGraph(rootId),
select: (raw) => normalizeGraph(raw), // normalization runs at the cache boundary
staleTime: 30_000,
gcTime: 5 * 60_000,
});
Cache Behavior Analysis. The select option in React Query v5 is memoized per query key — it re-runs only when the underlying queryFn result changes, not on every subscriber render. Placing normalizeGraph inside select means the flat Map is computed once per fetch, stored in the query cache under ['graph', rootId], and served to all subscribers without re-traversal. React Query’s structuralSharing compares the new Map entries against the previous ones; unchanged entities retain their previous references, preventing unnecessary re-renders in components that read only unaffected nodes.
Edge Cases and Gotchas
WeakSet cannot be used in Node.js SSR serialization paths
WeakSet works correctly in browser environments and Node.js for in-memory cycle detection, but it cannot be serialized or iterated. In an SSR context where you serialize the query cache into __NEXT_DATA__ or a Remix loader response, replace the WeakSet with a plain Set<unknown> for the traversal guard. Be aware that a Set retains strong references to every visited object for the lifetime of the serialization call — acceptable for a single synchronous pass, but do not store it beyond that scope.
// SSR-safe variant: use Set instead of WeakSet
function serializeSSR(obj: unknown): string {
const seen = new Set<unknown>(); // strong refs — discard after this call
return JSON.stringify(obj, (_key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return { __circular: true };
seen.add(value);
}
return value;
});
// `seen` goes out of scope here — GC can reclaim everything
}
Apollo InMemoryCache can silently re-introduce cycles via typePolicies
Apollo Client v3’s InMemoryCache normalizes entities by __typename and id by default. Custom typePolicies that use read functions to embed related entities as full objects (rather than Reference handles) can recreate cycles inside the Apollo reactive variable graph. Symptom: Apollo DevTools shows correct data but serializing the store with JSON.stringify(client.extract()) throws. Resolution: always return Reference objects from read functions rather than reconstructed entity objects.
const cache = new InMemoryCache({
typePolicies: {
Comment: {
fields: {
post: {
// WRONG: returning a full object re-introduces a cycle
// read(_, { readField }) { return { id: readField('postId'), comments: [...] }; }
// CORRECT: return a Reference — Apollo resolves it lazily
read(existing) {
return existing; // existing is already a Reference handle
},
},
},
},
},
});
Depth limits protect against untrusted API responses
Third-party SDK responses and webhook payloads may contain unexpected nesting that is not technically circular but still causes stack overflows in naive recursive serializers. Add a depth counter alongside the WeakSet guard:
function safeNormalize(
node: Record<string, unknown>,
parentId: string | null = null,
cache: Map<string, unknown> = new Map(),
seen: WeakSet<object> = new WeakSet(),
depth = 0
): Map<string, unknown> {
const MAX_DEPTH = 12;
if (depth > MAX_DEPTH) {
console.warn('[cache] max normalization depth exceeded — caching partial node', node);
return cache;
}
if (seen.has(node) || cache.has(node.id as string)) return cache;
seen.add(node);
// ... normalization logic
return cache;
}
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
TypeError: Converting circular structure to JSON during localStorage.setItem |
A cache write placed a parent–child entity pair with direct object back-references into the persisted slice before serialization. | Wrap the write with serializeWithCycleGuard and normalize the payload to ID references before persistence. Confirm with findCircular(payload) returning true before the write. |
| Heap grows 20–40 MB per navigation without explicit leaks | Circular references between cache nodes and detached React tree nodes prevent the GC from reclaiming stale slices after query keys are removed. | Use Chrome DevTools → Memory → Heap Snapshot comparison. Set gcTime: 0 temporarily to force immediate cleanup; if heap stabilizes, a lingering circular reference is preventing eviction. Normalize to flat IDs and audit any WeakRef usage that inadvertently holds strong back-references. |
Apollo client.extract() throws on cache hydration |
A read function in typePolicies reconstructed a full entity object rather than returning a Reference, creating a cycle inside the reactive variable graph. |
Replace the read return value with the existing Reference handle. Validate with JSON.stringify(client.extract()) in a test environment after the change. |
Frequently Asked Questions
Can structuredClone handle circular references natively?
Yes. structuredClone preserves reference topology including cycles and does not throw a DataCloneError on circular inputs. However it returns a JavaScript value — not a JSON string — so it cannot replace JSON.stringify when targeting JSON-based storage, SSR serialization, or network transport. Use structuredClone when you need a deep in-memory copy that respects the cycle; use the WeakSet-replacer approach when you need a serialized string.
Does breaking circular references affect React Query's structuralSharing?
Yes, positively. React Query’s structuralSharing (controlled by the structuralSharing query option, true by default) performs deep referential equality during cache writes to reuse unchanged sub-trees. When you normalize a circular graph to flat ID strings, the comparison becomes straightforward string equality rather than object identity traversal. Changed entities get new string IDs or updated data shapes; unchanged entities keep their existing references. The result is more accurate re-render targeting and lower GC pressure compared to storing live object graphs.
How do I detect circular references in a production React app without crashing users?
Instrument your cache write path — a React Query QueryCache onError callback or a Redux middleware — with a non-throwing findCircular probe. When the probe returns true, log the query key and a safe representation of the shape to your error tracker (Sentry, Datadog), then either return the previous safe cache value or write a partial snapshot with a __partial: true flag. Pair this with gcTime set to a short interval on the affected query so the stale partial entry is evicted promptly once a corrected payload arrives. Never let the serialization call itself reach production without this guard.
Related
- Reference vs Value Storage Models — the parent strategy page covering when to choose normalized (reference) versus denormalized (value) storage and the lifecycle trade-offs of each.
- State Architecture & Cache Fundamentals — the top-level guide to cache layer design, client vs server state boundaries, and normalization principles that govern this technique.
- Flattening Deeply Nested GraphQL Responses — how to structurally flatten deeply nested payloads at the query boundary, a technique that also eliminates the nesting that often causes circular reference bugs.
- How to Design a Normalized State Tree — the architectural blueprint for building a flat entity map from scratch, which is the preventive solution to circular reference formation.