TypeError: Converting circular structure to JSON — Every Cause and Fix
JSON.stringify() is the one place in the JSON error family where the failure is not about text. Every other message in this series (Unexpected token, Unexpected end of input) comes from JSON.parse() rejecting a string. This one fires on the way out: you have a live JavaScript object, you try to turn it into text, and the engine refuses because the object contains itself. JSON is a tree, and your data is a graph with a loop in it. Below are the five shapes that loop usually takes, the exact message Node 22 prints for each, and the fix that does not quietly delete data. The fixed outputs open in JSON Lab so you can inspect what you would actually send.
parent, owner, next or self pointer). Read the path in the error message to find the back-reference, then either drop that property with a replacer, give the class a toJSON() method, or serialize ids instead of references. Do not reach for a "seen" set without reading section 4 first.
Reading the error message
Modern V8 (Node 12+ and current Chromium) prints the path of the cycle, which most guides on this error ignore. Take a parent that holds a child that points back:
const parent = { id: 1, children: [] };
const child = { id: 2, parent };
parent.children.push(child);
JSON.stringify(parent);
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'Object'
// | property 'children' -> object with constructor 'Array'
// | index 0 -> object with constructor 'Object'
// --- property 'parent' closes the circle
Read it top to bottom: the walk started at the root, went into children, then element 0, and the property parent on that element points back to the root. That last line is the property to remove or replace. The | lines are the route from the root, the --- line is the offending edge. Firefox words it differently (cyclic object value) and Safari says JSON.stringify cannot serialize cyclic structures, but the cause and fix are identical.
Quick reference
Every message below was produced by running the snippet on Node v22.22.0 before publishing.
| Shape | Last line of the message | Fix |
|---|---|---|
| Object holds itself | property 'self' closes the circle | Delete the self reference, or replace it |
| Child points to parent | property 'parent' closes the circle | Drop the back-reference in a replacer; the nesting already encodes it |
| Two objects reference each other | property 'x' closes the circle | Break one direction, serialize ids for the other |
| Linked list or class graph | property 'next' closes the circle (constructor named in the message) | Add toJSON() to the class; for a ring, emit an id for next instead of the node |
| Array contains itself | index 0 closes the circle | Remove the self-insert; check the code that pushed it |
1. An object that holds a reference to itself
The smallest case, and the one that turns up when someone attaches a convenience handle to an object: config.self = config, a framework context that stores itself, a wrapper that keeps a pointer to its own instance.
const a = { name: 'a' };
a.self = a;
JSON.stringify(a);
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'Object'
// --- property 'self' closes the circle
Fix: remove the property before serializing, or exclude it with a replacer: JSON.stringify(a, (k, v) => k === 'self' ? undefined : v). Returning undefined from a replacer omits the key entirely.
2. A child that points back to its parent
The most common real-world cause: trees and ORM-style models where each child keeps a parent (or owner, project, order) reference for convenient navigation. The parent-to-children direction already carries all the structure you need to rebuild the link, so the back-reference is redundant on the wire.
JSON.stringify(parent, (key, value) => key === 'parent' ? undefined : value);
// {"id":1,"children":[{"id":2}]}
Dropping a known back-reference key is the safest fix because it is explicit: it removes exactly one edge and nothing else. The opposite approach, a generic cycle detector, is where data gets lost (section 4). If you are not sure which property is the culprit, the last line of the error message tells you.
See it live
The tree after the back-reference is replaced with a marker, pretty-printed. Open it to check what you would send over the wire:
Open this example in JSON Lab3. Two objects that reference each other
A pair rather than a tree: x.y = y and y.x = x. Typical in bidirectional relations (user and team, author and book) loaded from a database layer that wires both sides. The message names the two edges involved:
const x = {}; const y = { x }; x.y = y;
JSON.stringify(x);
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'Object'
// | property 'y' -> object with constructor 'Object'
// --- property 'x' closes the circle
Break one direction and serialize an id for it instead: send { teamId: 7 } on the user rather than the whole team object that lists the user. That is also the better API design, since it keeps the payload size independent of how connected your data is.
4. The generic "seen" replacer, and the data it silently drops
The popular Stack Overflow answer keeps a WeakSet of every object visited and replaces repeats with a marker. It does stop the throw. It also treats any object that appears twice as circular, including objects that are merely shared, which is legal in JSON:
const shared = { z: 1 };
const d = { a: shared, b: shared }; // no cycle at all
const seen = new WeakSet();
JSON.stringify(d, (k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v)) return '[Circular]';
seen.add(v);
}
return v;
});
// {"a":{"z":1},"b":"[Circular]"} <- b lost its data, and nothing was circular
See it live
This is the corrupted output. b became the string "[Circular]" although d has no cycle. Paste it next to the correct output below:
A replacer that only flags an object currently on the path from the root (an ancestor) gets it right, because a shared sibling is not an ancestor of itself:
function safeStringify(obj, space) {
const stack = [];
return JSON.stringify(obj, function (key, value) {
if (typeof value !== 'object' || value === null) return value;
// `this` is the object that holds `key`; unwind to it
while (stack.length && stack[stack.length - 1] !== this) stack.pop();
if (stack.includes(value)) return '[Circular]';
stack.push(value);
return value;
}, space);
}
safeStringify(d); // {"a":{"z":1},"b":{"z":1}}
safeStringify(parent); // {"id":1,"children":[{"id":2,"parent":"[Circular]"}]}
See it live
The correct output for the shared-reference case: both a and b keep their data.
The ancestor check is quadratic in depth because of includes, which is irrelevant for config-sized objects and worth knowing before you run it on a 50,000-node tree. For large graphs, prefer removing the known back-reference key or serializing ids.
5. Class instances, linked lists and framework objects
Linked lists, doubly-linked nodes and ORM entities all produce cycles by construction. The message names the constructor (object with constructor 'Node_' in my test), which immediately tells you which class owns the problem. The cleanest fix lives on the class, so every call site is safe:
class ListNode {
constructor(value) { this.value = value; this.next = null; this.prev = null; }
toJSON() {
// serialize forward only; prev is derivable
return { value: this.value, next: this.next };
}
}
That works for a doubly-linked list whose far end is null, because the omitted prev never enters the traversal (I ran it: {"value":1,"next":{"value":2,"next":null}}). It does not work for a ring, where the last node's next points back to an earlier node. toJSON() returns a fresh wrapper object on every visit, so V8 never sees the same object twice and recursion continues until RangeError: Maximum call stack size exceeded (also run on Node 22). For a ring, break the forward edge as well, for example by emitting the neighbour's id instead of the neighbour: next: this.next && this.next.value. A related trap: DOM nodes, React fiber objects and many framework internals are cyclic and enormous. If you are logging one, use console.log (Node prints [Circular *1] rather than throwing) instead of stringifying it, and pick out the three fields you need.
What about structuredClone?
structuredClone(parent) copies cyclic graphs correctly (I confirmed clone.children[0].parent === clone on Node 22), which makes it tempting as a "fix". It solves a different problem: it duplicates an object in memory and returns another object, not JSON text. If you need to send the data somewhere, you still need one of the strategies above.
Debugging checklist
- Read the last line of the error:
property 'x' closes the circlenames the back-reference. Start there. - Decide whether that property carries information the other direction does not already encode. Parent pointers almost never do.
- Prefer an explicit fix (drop that key, add
toJSON(), send ids) over a generic cycle detector. - If you do use a generic replacer, use the ancestor-aware version, and test it with a shared (non-circular) reference so you know it does not drop data.
- Check the output in a formatter before shipping it: a
"[Circular]"string where real data should be is easy to miss in a minified blob.
Related: if this exact data comes back as the text [object Object] on the other side, see "[object Object]" is not valid JSON, and for parse failures on the receiving end see Unexpected end of JSON input.
📋 Check what you are about to send
JSON Lab formats, validates, and repairs JSON entirely in your browser, so nothing you paste is uploaded anywhere.
Open JSON Lab →