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.


Circular Reference Cycle vs Normalized ID Graph Left side: parent node holds a direct object reference to child, child holds a direct object reference back to parent — forming a cycle. Right side: parent stores childId string, child stores parentId string — cycle broken. CYCLE (dangerous) NORMALIZED (safe) parent child: {…} child parent: {…} CYCLE parent childId: "child-42" child parentId: "parent-7" ID ref only

Diagnostic Checklist

Before writing any fix, confirm you are facing a circular reference rather than an unrelated serialization failure.

  • TypeError: Converting circular structure to JSON appears during a cache write, localStorage.setItem, or SSR __NEXT_DATA__ injection.
  • RangeError: Maximum call stack size exceeded fires 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 but JSON.stringify() throws — this is diagnostic: structuredClone tolerates 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.