Fix: TypeError: Converting circular structure to JSON
Part of: JavaScript & TypeScript Errors
Quick Answer
Fix 'Converting circular structure to JSON' with a replacer function, and find the real source: DOM nodes, Mongoose documents, or Express request objects.
The object I was trying to log looked completely ordinary in the debugger, a handful of plain fields, nothing that looked circular. The culprit turned out to be one field holding a reference back to the very request object I’d pulled it from, and Express’s request object is deeply self-referential internally. In my experience this error is rarely about the field you’re staring at, it’s almost always something a few properties deep that quietly holds a reference back up the chain. This covers the exact wording across engines, why JSON has no way to represent this at all, and the fixes in order of how much they change your actual data.
TypeError: Converting circular structure to JSON
JSON.stringify() throws this in Node.js and Chrome, and V8 names exactly where the cycle closes:
TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
--- property 'parent' closes the circle
at JSON.stringify (<anonymous>)Firefox reports the same failure with much less detail:
TypeError: cyclic object valueSafari’s phrasing is shorter still:
TypeError: JSON.stringify cannot serialize cyclic structures.All three mean the same thing: somewhere in the object you handed to JSON.stringify(), a property points back to an object that is already being serialized, directly or through a chain of other objects. Only V8 tells you which property closes the loop, which makes Node and Chrome by far the easiest place to actually debug this.
Why JSON has no way to represent this
JSON is a tree format, not a graph format. Every value in valid JSON is either a primitive or a nested object/array containing more of the same, there is no JSON syntax for “this is the same object as the one three levels up.” When JSON.stringify() walks an object graph and revisits something it is still in the middle of serializing, it has no representation to fall back on, so it throws instead of guessing. This is a deliberate limitation of the format, not a missing feature in JSON.stringify() itself.
The most common real sources of an accidental cycle:
- A parent/child data structure where a child object stores a reference back to its own parent, and the parent’s list of children includes that same child.
- Framework and library objects that are internally self-referential. Express’s
req/resobjects, DOM nodes, and Mongoose documents all hold internal back-references (a DOM node’sparentNode, a request’s underlying socket, a document’s internal change-tracking state) that were never meant to be serialized in the first place. - Logging an entire object “just in case” instead of the specific fields you actually need, which is how a request object or a class instance with internal bookkeeping ends up inside
JSON.stringify()at all. - Error objects with a
.causechain that, through unrelated code elsewhere, ends up pointing back to something that eventually references the error itself.
Find the actual cycle with the property name V8 gives you
Read the --- property 'X' closes the circle line literally, X is the exact property that, if you removed it, would break the cycle. Don’t guess based on which variable you passed to JSON.stringify(), the cycle is very often several properties deep inside it:
const parent = { name: 'root', children: [] };
const child = { name: 'leaf', parent }; // back-reference to parent
parent.children.push(child);
JSON.stringify(parent);
// --- property 'parent' closes the circleIf you’re on Firefox or Safari and only get the generic message, temporarily reproduce the same call in Node or Chrome DevTools to get the specific property name. I’ve spent far less time on this bug since I started doing that instead of adding console.log statements around every candidate property.
Fix it with a replacer function
JSON.stringify() accepts a second argument, a replacer function, that runs once per key/value pair. Track objects you’ve already visited with a WeakSet, and drop any property that would revisit one:
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":"root","children":[{"name":"leaf","parent":"[Circular]"}]}A WeakSet is the conventional choice for this kind of “have I seen this object” membership check, since it never needs manual cleanup and, unlike a plain Set, never risks retaining an object longer than necessary if this pattern gets copied into a longer-lived cache elsewhere. In this specific replacer, scoped to a single JSON.stringify() call, a plain Set would behave identically, the choice matters more once this pattern is reused outside a one-off function.
For anything beyond an occasional debug log, reach for a maintained library instead of re-deriving this, flatted and json-stringify-safe both handle nested and repeated (not just strictly circular) references correctly, including cases a hand-rolled WeakSet replacer misses, like the same object appearing twice in unrelated branches rather than forming a true cycle.
Fix it by not stringifying the whole object
The replacer function treats the symptom. The actual fix, more often than not, is that a raw framework or library object should never have reached JSON.stringify() in the first place. Pull out only the fields you need:
// BUG: logging the entire request, which contains circular internals
app.use((req, res, next) => {
console.log(JSON.stringify(req)); // throws
next();
});// FIX: extract only the plain data you actually want to log
app.use((req, res, next) => {
console.log(JSON.stringify({ method: req.method, url: req.url, headers: req.headers }));
next();
});The same applies to Mongoose documents, call .toObject() or .toJSON() first, both strip the internal state that causes this, and to DOM nodes, extract the specific properties you care about rather than serializing the element itself.
structuredClone handles cycles, but it doesn’t produce a JSON string
structuredClone() genuinely does support circular references without throwing, this is a real, spec-level difference from JSON.stringify(). But it solves a different problem: it clones a value in memory, it does not produce a JSON string, and JSON as a wire format still has no way to represent a cycle:
const clone = structuredClone(parent); // works fine, no error
JSON.stringify(clone); // still throws: the cycle survived the cloneReach for structuredClone when you need a deep copy to hand to a Worker, postMessage, or IndexedDB, all of which use the same structured clone algorithm internally. Reach for a replacer function when you actually need JSON text, over the network, in a log line, written to a file.
Debugging without stringifying at all
If the goal is just to inspect the object, not produce JSON, skip JSON.stringify() entirely. console.log() uses Node’s util.inspect() under the hood, which is built to handle cycles and prints them clearly instead of throwing:
console.log(parent);
// <ref *1> { name: 'root', children: [ { name: 'leaf', parent: [Circular *1] } ] }<ref *1> marks the object being pointed back to, and [Circular *1] marks where the cycle closes, matched by the same number. This is often all you need for a debugging session, and it requires no workaround at all.
Still not working?
The cycle isn’t a true self-reference, it’s the same object appearing twice. JSON.stringify() only throws for an actual cycle, an object that contains itself through some chain, not merely for the same object referenced from two different places with no cycle between them. If you’re seeing unexpected duplication instead of a crash, that’s a different, non-erroring case, and a WeakSet replacer will silently drop the second occurrence rather than erroring, which can hide data you actually wanted.
The error only appears in production, not locally. Different code paths often build the object differently; a dev-only mock might be a clean plain object while the production path attaches a real framework or ORM object with internal back-references. Reproduce with production-shaped data before assuming the replacer fix alone is enough.
A third-party library throws this internally, with a stack trace that never touches your own code. Something you passed to the library (a callback context, a config object, a live connection object) is being logged or serialized by the library itself. Check the library’s documentation for what it expects you to pass versus what it expects to construct internally.
You need the reverse operation. This error is about producing JSON from an object; if you’re instead failing to parse JSON text back into an object, that’s a different, unrelated error. See Fix: Unexpected token in JSON for that one specifically.
For related serialization and stack-related errors, see Fix: JavaScript Maximum Call Stack Size Exceeded and Fix: Express req.body Undefined.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Error: spawn ENOENT (Node.js child_process)
Fix Node.js 'Error: spawn ENOENT' from child_process.spawn(): PATH resolution, the Windows .cmd/.bat problem, and why shell: true is now discouraged (DEP0190).
Fix: Cannot set headers after they are sent to the client
Fix Node.js/Express 'Cannot set headers after they are sent to the client', usually a missing return, double next(), or a duplicate response.
Fix: TypeError: fetch failed (Node.js)
Fix Node.js 'TypeError: fetch failed' by reading error.cause to find what really failed: DNS, a refused connection, a timeout, or a bad certificate.
Fix: Error [ERR_REQUIRE_ESM]: require() of ES Module not supported
Fix Node.js 'Error [ERR_REQUIRE_ESM]: require() of ES Module not supported' by checking your Node version, using dynamic import(), or converting to ESM.