Quick answer:
JSON.stringify()throwsTypeError: Converting circular structure to JSONwhen an object contains a reference back to itself, directly or through a chain of properties. JSON cannot represent references, so the serializer gives up. The best fix is to serialize only the fields you need. If you cannot, pass a replacer function that swaps already-seen objects for a placeholder, or define atoJSON()method on the class.
You add one line to an error handler, res.status(500).json({ error, req }), deploy, and the handler itself starts crashing. The stack trace does not point at your code; it points at JSON.stringify. That is this error. It almost never comes from data you built by hand. It comes from framework objects that were never meant to be serialized whole.
After addressing the encoding or serialization issue, inspect a valid JSON sample in the JSON Viewer.
What the Error Looks Like
Node and every Chromium browser produce the same V8 message, and modern versions include a path:
const parent = { name: "root", children: [] };
const child = { name: "leaf", 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 bottom-up. The last line names the property that completed the loop (parent), and the lines above trace the route from the root. That path is usually all you need to decide which field to drop. Firefox gives the shorter TypeError: cyclic object value, and Safari says JSON.stringify cannot serialize cyclic structures, with no path in either.
Where Circular References Come From
Hand-built data rarely has cycles. Framework and library objects almost always do, because they keep back-references for convenience. These are the usual suspects:
| Object | Constructor in the error | Why it loops |
|---|---|---|
| Express req / res | Socket, IncomingMessage | req.socket.parser points back to the socket |
| Axios error | ClientRequest | error.request and error.config reference each other |
| React element or DOM node | FiberNode | Fiber nodes link parent, child, and sibling in a loop |
| Mongoose document (spread) | NativeConnection, Model | Internal $__ state references the model and connection |
| Tree with parent pointers | Object | child.parent === parent, parent.children contains child |
| Event emitters, DB clients | Socket, Pool | Connection pools keep back-references to clients |
Two of these deserve a note. React attaches an own property named __reactFiber$... to every DOM element it renders, so stringifying a DOM node in a React app walks straight into the fiber tree and fails with constructor 'FiberNode'. And Mongoose documents stringify fine on their own because Mongoose defines toJSON; the error appears when someone spreads one into a plain object with { ...doc }, which copies the internal state and loses the toJSON method in one move.
Fix 1: Serialize Only What You Need (Best)
In nine cases out of ten the right answer is not a cleverer serializer. It is not sending the whole object. Framework objects contain sockets, timers, and buffers that have no business in a response or a log line.
// Express error handler
app.use((err, req, res, next) => {
res.status(500).json({
message: err.message,
request: { method: req.method, url: req.originalUrl, id: req.id },
});
});
// Axios error in a catch block
} catch (err) {
log({ status: err.response?.status, data: err.response?.data, url: err.config?.url });
}
Axios actually ships a toJSON() on its error objects for exactly this reason, so JSON.stringify(err) works there, but it still produces more than you want to log. Picking fields is also the fix that survives a library upgrade: a replacer that worked on last year's internals may silently serialize megabytes of new ones.
Fix 2: A Replacer Function With a WeakSet
When you genuinely do not control the shape, for example a generic debug dump, use the second argument of JSON.stringify. The replacer sees every value before it is written and can substitute anything it has already visited:
function safeStringify(value, space = 2) {
const seen = new WeakSet();
return JSON.stringify(
value,
(key, val) => {
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return "[Circular]";
seen.add(val);
}
return val;
},
space
);
}
safeStringify(parent);
// {
// "name": "root",
// "children": [ { "name": "leaf", "parent": "[Circular]" } ]
// }
A WeakSet rather than an array keeps lookups constant-time and lets the garbage collector reclaim the objects afterward. The catch: this version flags any repeated reference, not just true cycles. If the same address object appears under both billing and shipping, the second one becomes "[Circular]" even though no loop exists. For most logging that is acceptable. If it is not, track ancestors instead of all visited nodes. Inside a replacer, this is the object that holds the current value, which makes that possible:
function stringifyCycles(value, space = 2) {
const ancestors = [];
return JSON.stringify(
value,
function (key, val) {
if (typeof val !== "object" || val === null) return val;
// unwind to the holder of the current value
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
ancestors.pop();
}
if (ancestors.includes(val)) return "[Circular]";
ancestors.push(val);
return val;
},
space
);
}
Note the function keyword; an arrow function would not receive this. The output of either helper is ordinary JSON, so you can paste it into JSON Prettifier and turn on Sort Keys to compare two dumps side by side.
Fix 3: Use util.inspect or console.dir for Logging
If the goal was never JSON but a readable dump, stop using JSON.stringify for it. Node's util.inspect handles cycles, prints class names, and lets you cap depth:
import { inspect } from "node:util";
console.log(inspect(parent, { depth: 4, colors: true }));
// <ref *1> {
// name: 'root',
// children: [ { name: 'leaf', parent: [Circular *1] } ]
// }
Plain console.log(obj) does the same thing with a default depth of 2. In the browser, console.dir(obj) gives an expandable tree. The error only bites when something turns the object into a string, which is why it surfaces in loggers that emit JSON, in localStorage.setItem, and in postMessage wrappers. If you are building structured JSON logs, give the logger a serializer or pass selected fields rather than raw framework objects.
Fix 4: Add a toJSON() Method to Your Class
For your own classes, the cleanest long-term fix lives on the class. JSON.stringify calls toJSON() on any object that has one and serializes the return value instead of the object:
class TreeNode {
constructor(name, parent = null) {
this.name = name;
this.parent = parent;
this.children = [];
if (parent) parent.children.push(this);
}
toJSON() {
return {
name: this.name,
parentName: this.parent ? this.parent.name : null,
children: this.children, // stringify recurses and calls toJSON on each
};
}
}
const root = new TreeNode("root");
new TreeNode("leaf", root);
JSON.stringify(root);
// {"name":"root","parentName":null,"children":[{"name":"leaf","parentName":"root","children":[]}]}
This is the same mechanism Date uses to become an ISO string, and it works with res.json(), loggers, and anything else that calls JSON.stringify under the hood. The JSON.parse and JSON.stringify guide covers the full set of hooks, including the reviver for the way back.
Fix 5: The flatted Package (When You Must Round-Trip the Cycle)
Every fix above throws the cycle away. If you need to store a graph and rebuild it with the links intact, the flatted package encodes references as indexes:
import { stringify, parse } from "flatted";
const text = stringify(parent);
// [{"name":"1","children":"2"},"root",["3"],{"name":"4","parent":"0"},"leaf"]
const copy = parse(text);
copy.children[0].parent === copy; // true
The output is valid JSON, but it is flatted's own layout, not your object's. Only flatted.parse can read it, so it is useful for a cache or a snapshot, not for an API response that a Python or Go client will consume. Treat it as a last resort.
Common Mistakes
- Reaching for structuredClone.
structuredClone()handles cycles, but it produces an object, not a string, so it does not help you send or store the data. It also throws on functions and DOM nodes. See deep cloning with JSON.parse(JSON.stringify()) for where each tool fits. - Catching the error and sending nothing. Wrapping
res.jsonin a try/catch that swallows the TypeError leaves the client waiting on a response that never comes. - Deleting the property on the live object.
delete req.socketbefore serializing breaks the request for every later middleware. Copy fields out instead. - Logging the whole error from a database driver. Driver errors often carry the client or pool. Log
err.message,err.code, anderr.stack. - Confusing this with other stringify failures.
BigIntvalues throw a different TypeError, andundefinedor functions are silently dropped. Those are covered in our common JSON errors roundup.