DevDockTools

How to Safely Stringify Circular References in JSON

Fix 'Converting circular structure to JSON' errors with a custom replacer, WeakSet tracking, or structuredClone — without losing your data.

By Daniel Agrici5 min read
JSONJavaScriptcircular referencedebuggingTypeError

TypeError: Converting circular structure to JSON is one of the more disruptive runtime errors because it usually surfaces far from its cause — a logging call, a Redux devtools serialization step, an API response builder — while the actual circular reference was introduced somewhere else entirely, often a parent object holding a reference back to a child that itself references the parent.

Why This Happens

JSON.stringify recursively walks an object graph, and JSON as a format has no concept of references or shared structure — every value must be fully inlined. When the serializer encounters an object it's already in the process of serializing (an ancestor in the current recursion stack), it can't represent that relationship in JSON at all, so it throws rather than recursing forever. This is defined behavior per the ECMAScript spec's JSON.stringify abstract operations, not a bug.

A minimal reproduction:

const parent = { name: 'parent' };
const child = { name: 'child', parent };
parent.child = child;

JSON.stringify(parent);
// TypeError: Converting circular structure to JSON

This is extremely common with DOM nodes (which reference their parent and children), ORM entities with bidirectional relations, and Vue/React internal objects accidentally included in a payload someone tried to log.

Option 1: A Custom Replacer With Cycle Detection

The standard fix is a replacer function passed as JSON.stringify's second argument, tracking visited objects with a WeakSet:

function getCircularReplacer() {
  const seen = new WeakSet();
  return (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) {
        return '[Circular]';
      }
      seen.add(value);
    }
    return value;
  };
}

JSON.stringify(parent, getCircularReplacer());
// {"name":"parent","child":{"name":"child","parent":"[Circular]"}}

WeakSet is the right structure here rather than a plain array or Set with manual cleanup — it doesn't prevent garbage collection of objects after stringification completes, and lookups are O(1). Note this replacer marks any repeated reference as circular, not just true cycles — if the same object appears twice in the tree without forming an actual cycle (e.g., two siblings pointing at a shared config object), it'll also be flattened to "[Circular]". That's usually the desired behavior for logging, but be aware it's technically over-broad relative to true cycle detection.

Option 2: True Cycle-Only Detection (Path-Aware)

If you need to distinguish an actual cycle from a merely-repeated reference, track the current recursion path instead of all visited objects, removing entries as you unwind:

function stringifySafe(obj) {
  const ancestors = [];
  return JSON.stringify(obj, function (key, value) {
    if (typeof value !== 'object' || value === null) {
      return value;
    }
    while (ancestors.length && ancestors[ancestors.length - 1] !== this) {
      ancestors.pop();
    }
    if (ancestors.includes(value)) {
      return '[Circular]';
    }
    ancestors.push(value);
    return value;
  });
}

This correctly serializes a repeated-but-non-circular reference twice in full, and only substitutes [Circular] where a true ancestor cycle exists. It costs more per call than the simple WeakSet version, so reserve it for cases where the distinction actually matters to your output.

Option 3: Strip the Offending Property Instead

Sometimes the circular reference is a property you don't actually want serialized at all — a parent back-reference on a tree node, a _reactInternals field accidentally attached to a DOM-adjacent object. If you know which key is the culprit, exclude it directly rather than substituting a placeholder string:

JSON.stringify(node, (key, value) => (key === 'parent' ? undefined : value));

Returning undefined from a replacer omits that key entirely from the output — cleaner than [Circular] when the excluded data genuinely isn't needed downstream.

Option 4: structuredClone for Non-JSON Use Cases

If you don't actually need a JSON string — you need a deep copy, for instance, to pass to a Web Worker or store in IndexedDB — structuredClone handles circular references natively, since the structured clone algorithm explicitly supports cyclical object graphs:

const clone = structuredClone(parent); // works fine, no error

This doesn't solve the "I need a JSON string" problem — structuredClone's output is a live JS object, not text — but it's the right tool when your actual goal was cloning or cross-context transfer rather than serialization to text.

Decision Point: Which Approach to Use

  • Debug logging, error reporting, devtools output — use the simple WeakSet replacer (Option 1). Losing the distinction between "repeated reference" and "true cycle" doesn't matter for a human reading a log line.
  • Data you'll deserialize and expect structurally faithful (config export/import, test fixtures) — use path-aware detection (Option 2), since a false-positive [Circular] on a non-circular repeated reference would silently corrupt the round-trip.
  • You know exactly which property causes the cycle and don't need it — strip it directly (Option 3); it's the cleanest output and avoids placeholder noise entirely.
  • You don't need JSON text at all, just a safe copy — skip JSON.stringify altogether and use structuredClone (Option 4).

Verifying the Fix

Once you've applied a replacer, validate the resulting string is well-formed by running it through the JSON Validator — a working replacer produces syntactically valid JSON by construction, but it's a fast sanity check if you're debugging a more complex custom replacer that also transforms other fields. For inspecting the shape of a large stringified object with several [Circular] markers scattered through it, the JSON Formatter makes the nesting easier to scan than raw output.

If you're seeing this error in a codebase you don't fully control — a third-party object passed into your logging pipeline, for instance — default to Option 1. It's the least invasive fix, requires no knowledge of the object's internal structure, and reliably prevents the crash regardless of where the cycle originates.

Frequently Asked Questions

Why does JSON.stringify throw on circular references?
JSON.stringify walks the object graph recursively and has no built-in mechanism to detect a reference back to an ancestor object. When it re-encounters an object already being serialized, it would recurse infinitely, so the spec-defined behavior is to throw a TypeError instead.
Does structuredClone handle circular references?
Yes, structuredClone natively supports circular references because it uses the structured clone algorithm rather than JSON serialization. It will happily clone an object that references itself, but the result is a JS object, not a JSON string — you still need a replacer-based approach if you need actual JSON text output.
What's the fastest way to just find where the circular reference is?
Use a try/catch around JSON.stringify and inspect the TypeError message in V8-based environments (Node, Chrome) — recent versions include a property path pointing at the circular reference. If that's unavailable, walk the object with a WeakSet-tracking function that throws the exact key path on first cycle detection.