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
WeakSetreplacer (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.stringifyaltogether and usestructuredClone(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.