Skip to content
JavaScript

Fix 'Converting Circular Structure to JSON' in JavaScript

·7 min read
Fix 'Converting Circular Structure to JSON' in JavaScript - JavaScript guide from JSON Prettifier

Quick answer: JSON.stringify() throws TypeError: Converting circular structure to JSON when 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 a toJSON() 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:

ObjectConstructor in the errorWhy it loops
Express req / resSocket, IncomingMessagereq.socket.parser points back to the socket
Axios errorClientRequesterror.request and error.config reference each other
React element or DOM nodeFiberNodeFiber nodes link parent, child, and sibling in a loop
Mongoose document (spread)NativeConnection, ModelInternal $__ state references the model and connection
Tree with parent pointersObjectchild.parent === parent, parent.children contains child
Event emitters, DB clientsSocket, PoolConnection 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.json in 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.socket before 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, and err.stack.
  • Confusing this with other stringify failures. BigInt values throw a different TypeError, and undefined or functions are silently dropped. Those are covered in our common JSON errors roundup.

Frequently Asked Questions

What does 'Converting circular structure to JSON' mean?
JSON.stringify walked the object, followed a property, and arrived back at an object it had already visited. JSON has no way to express a reference, so the serializer would loop forever; instead it throws a TypeError and names the property that closed the circle.
Why does res.json(req) fail in Express?
The request object carries the underlying socket, and the socket holds a parser that references the request again. Send only the fields you need, such as req.method, req.url, req.headers, and req.body, instead of the whole object.
How do I JSON.stringify an object with circular references?
Pass a replacer function that tracks visited objects in a WeakSet and returns a placeholder like '[Circular]' for repeats, or give the class a toJSON method that returns only the fields you want. If you must round-trip the cycle, the flatted package encodes references in a format its own parse function can restore.
Does console.log have the same problem?
No. Node's console.log and the browser console both detect cycles and print a marker such as [Circular *1]. The error only appears when you call JSON.stringify directly or through something that calls it for you, such as res.json, localStorage code, or a logger that serializes objects.
What is the Firefox and Safari version of this error?
Firefox reports 'TypeError: cyclic object value' and Safari reports 'TypeError: JSON.stringify cannot serialize cyclic structures.' Chrome, Edge, and Node (all V8) say 'Converting circular structure to JSON' and add the property path that closed the loop.

Check the Output You Finally Produced

Paste the serialized result, format it, and use Sort Keys to compare dumps. Nothing leaves your browser.

Format My JSON