JSON Circular Reference

A circular reference in JavaScript means an object points back to itself — directly or through a chain of other objects. That is perfectly legal in memory, but JSON.stringify() must traverse the entire graph to produce a string, and JSON has no syntax for "same object as the one at path X." The result is an immediate TypeError: Converting circular structure to JSON. This guide covers every practical fix: removing the cycle, a custom WeakSet replacer, the flatted library, toJSON(), and how to detect circular references before they reach stringify.

After removing a circular reference, validate the resulting JSON in Jsonic.

Open JSON Formatter

What causes the circular reference error

The error fires as soon as JSON.stringify() encounters a property whose value is an object it has already started visiting. The simplest example is an object that references itself:

const obj = { name: 'Alice' }
obj.self = obj   // obj now points back to itself

JSON.stringify(obj)
// Uncaught TypeError: Converting circular structure to JSON
//   --> starting at object with constructor 'Object'
//   --- property 'self' closes the circle

// Indirect cycle through two objects
const parent = { name: 'parent' }
const child  = { name: 'child' }
parent.child = child
child.parent = parent   // child → parent → child → ...

JSON.stringify(parent)
// TypeError: Converting circular structure to JSON

// Deeply nested chain eventually throws RangeError too:
// Maximum call stack size exceeded (on some engines)

Both direct self-references (obj.self = obj) and indirect cycles (A → B → A) trigger the same error. The error message in V8 (Node.js, Chrome) helpfully names the property that closes the circle. In Firefox the message is shorter: cyclic object value.

Note that structuredClone(), built into browsers since 2022 and Node.js 17+, does correctly deep-clone objects that contain circular references — the cycle is preserved in the clone. But if you then pass that clone to JSON.stringify(), you get the same TypeError. structuredClone() solves the in-memory copying problem, not the serialization problem.

Solution 1: Remove the circular reference

The cleanest fix is to redesign the data so the cycle does not exist. This is appropriate when the back-reference is added for convenience (e.g., a parent pointer in a tree) but is not needed in the serialized output.

// Before: doubly-linked tree with circular refs
const root = { name: 'root', children: [] }
const leaf = { name: 'leaf', parent: root }
root.children.push(leaf)

// JSON.stringify(root) → TypeError

// After: remove the parent back-pointer before stringifying
function treeToJson(node) {
  return {
    name: node.name,
    children: node.children.map(treeToJson),   // recurse, omit parent
  }
}

JSON.stringify(treeToJson(root))
// '{"name":"root","children":[{"name":"leaf","children":[]}]}'

If the circular property is only needed at runtime (navigation, event listeners, DOM references), strip it before serializing rather than carrying it in the data model. This approach produces clean, portable JSON with zero extra dependencies.

Solution 2: Custom replacer with WeakSet (most common)

JSON.stringify() accepts a replacer function as its second argument. You can use this to intercept each value before it is serialized. A WeakSet tracks which objects have already been visited; returning undefined for any revisited object drops it from the output. Memory usage is O(n) relative to the number of unique objects in the graph, and the WeakSet entries are garbage-collected along with the objects themselves.

function stringifySafe(value, indent) {
  const seen = new WeakSet()

  return JSON.stringify(value, function replacer(key, val) {
    // Only objects can be circular; primitives are always safe
    if (typeof val === 'object' && val !== null) {
      if (seen.has(val)) {
        return undefined   // drop the circular reference
      }
      seen.add(val)
    }
    return val
  }, indent)
}

// --- Usage ---
const obj = { id: 1, name: 'Alice' }
obj.self = obj

console.log(stringifySafe(obj, 2))
// {
//   "id": 1,
//   "name": "Alice"
//   // "self" is omitted — the circular value was replaced with undefined
// }

// Indirect cycle
const a = { label: 'a' }
const b = { label: 'b', ref: a }
a.ref = b

console.log(stringifySafe(a))
// '{"label":"a","ref":{"label":"b"}}' — second visit to 'a' is dropped

The trade-off: data is lost. The circular reference itself is removed from the output as undefined, so JSON.parse() of the result will not reconstruct the original graph. This is ideal for logging and debugging where completeness matters less than not crashing.

Solution 3: Use the flatted library

flatted (1.4 KB gzipped) is a drop-in replacement for JSON.stringify / JSON.parse that fully preserves circular structures by encoding each unique object once and replacing subsequent references with a string ID. The round-trip is lossless.

// npm install flatted
import { stringify, parse } from 'flatted'

const obj = { id: 1, name: 'Alice' }
obj.self = obj

const encoded = stringify(obj)
console.log(encoded)
// '[{"id":1,"name":"Alice","self":"0"},"0"]'
// The "0" string is a placeholder for "index 0 in the array" (the root object)

const restored = parse(encoded)
console.log(restored.self === restored)   // true — cycle is fully restored
console.log(restored.name)               // 'Alice'

// Works with nested cycles too
const parent = { name: 'parent', children: [] }
const child  = { name: 'child',  parent }
parent.children.push(child)

const s = stringify(parent)
const r = parse(s)
console.log(r.children[0].parent === r)  // true

Use flatted when you need to preserve the full object graph — for example, persisting complex state to localStorage or sending it over a WebSocket where both ends can use the same library. The output is not standard JSON and cannot be read by JSON.parse() — always pair flatted.stringify with flatted.parse.

Solution 4: toJSON() method on the object

Any object can define a toJSON() method. When JSON.stringify() encounters an object with toJSON, it calls that method and serializes the returned value instead. This gives you full control over what gets serialized, right on the class itself.

class TreeNode {
  constructor(name, parent = null) {
    this.name = name
    this.parent = parent    // would be circular if included
    this.children = []
  }

  addChild(node) {
    node.parent = this
    this.children.push(node)
    return node
  }

  // JSON.stringify calls this automatically
  toJSON() {
    return {
      name: this.name,
      // omit this.parent — would cause circular reference
      children: this.children,   // children recurse, each calling toJSON()
    }
  }
}

const root = new TreeNode('root')
const child = root.addChild(new TreeNode('child'))
child.addChild(new TreeNode('grandchild'))

console.log(JSON.stringify(root, null, 2))
// {
//   "name": "root",
//   "children": [
//     {
//       "name": "child",
//       "children": [{ "name": "grandchild", "children": [] }]
//     }
//   ]
// }

toJSON() is the most encapsulated solution: the serialization logic lives with the class, consumers can use plain JSON.stringify(), and you can choose exactly which properties to include or transform. The built-in Date object uses this same mechanism — Date.prototype.toJSON() returns an ISO 8601 string.

How to detect circular references before stringify

Sometimes you want to validate an object before attempting to serialize it — for example, to surface a helpful error message in a form or API boundary rather than letting an uncaught TypeError crash your app.

function hasCircularReference(obj) {
  const seen = new WeakSet()

  function detect(value) {
    if (typeof value !== 'object' || value === null) return false
    if (seen.has(value)) return true   // found a cycle
    seen.add(value)
    for (const key of Object.keys(value)) {
      if (detect(value[key])) return true
    }
    seen.delete(value)   // backtrack so sibling subtrees don't false-positive
    return false
  }

  return detect(obj)
}

// --- Usage ---
const safe = { a: 1, b: { c: 2 } }
console.log(hasCircularReference(safe))   // false

const circ = { name: 'Alice' }
circ.self = circ
console.log(hasCircularReference(circ))   // true

// Safely stringify only if no cycle
function safeStringify(obj, indent) {
  if (hasCircularReference(obj)) {
    throw new Error('Cannot stringify: object contains a circular reference')
  }
  return JSON.stringify(obj, null, indent)
}

The seen.delete(value) backtrack step is important: without it, sharing (two properties pointing at the same object without a cycle) would be wrongly reported as circular. With backtracking, only genuine cycles return true.

Node.js also provides util.inspect() for debugging. It handles circular references natively by replacing them with [Circular *1] markers — useful for console output but not for producing JSON:

const util = require('util')

const obj = { name: 'Alice' }
obj.self = obj

// util.inspect handles circular refs — replaces with [Circular *1]
console.log(util.inspect(obj, { depth: 4 }))
// <ref *1> { name: 'Alice', self: [Circular *1] }

// For deep object inspection in Node.js without a crash:
console.log(util.inspect(obj, { depth: null, colors: true }))

Common sources: Express req/res, DOM nodes, class instances

Circular references crop up most often in objects that were never designed to be serialized. Here are the three most common culprits and how to handle each.

Express.js req and res objects

Express req and res objects contain references to the underlying Node.js socket, server, and each other — hundreds of circular links. Never pass them directly to JSON.stringify():

// BAD — crashes with TypeError: Converting circular structure to JSON
app.get('/debug', (req, res) => {
  res.json({ request: req })   // req has circular refs to socket, res, etc.
})

// GOOD — extract only the properties you actually need
app.get('/debug', (req, res) => {
  res.json({
    method:  req.method,
    url:     req.url,
    headers: req.headers,
    body:    req.body,
    query:   req.query,
    params:  req.params,
  })
})

DOM nodes

Every DOM element holds parentNode, ownerDocument,firstChild, and many other back-references that form a dense graph. Extract only the data you need before stringifying:

// BAD
const el = document.querySelector('h1')
JSON.stringify(el)   // TypeError — DOM element is deeply circular

// GOOD — extract the data you care about
JSON.stringify({
  tagName:   el.tagName,
  textContent: el.textContent,
  id:        el.id,
  classList: [...el.classList],
})

React state with circular objects

React's useState and useReducer hold plain JavaScript values, so circular references are possible if you accidentally mutate state in place. They also appear when storing references to class instances or third-party objects:

// Pattern that causes trouble: storing a class instance in state
class Graph {
  constructor() {
    this.nodes = []
    this.root = null
  }
  addNode(n) {
    n.graph = this       // back-reference to Graph → circular
    this.nodes.push(n)
  }
}

const [graph, setGraph] = useState(new Graph())

// Later, trying to serialize state for persistence:
localStorage.setItem('graph', JSON.stringify(graph))
// TypeError: Converting circular structure to JSON

// Fix: store only serializable data in React state
// Keep the Graph instance outside state (ref or module-level)
const graphRef = useRef(new Graph())

// Serialize only the plain data you need:
const serializableGraph = {
  nodes: graphRef.current.nodes.map(n => ({ id: n.id, label: n.label }))
}
localStorage.setItem('graph', JSON.stringify(serializableGraph))

Frequently asked questions

What is a circular reference in JavaScript?

A circular reference is when an object directly or indirectly references itself — for example, obj.self = obj, or obj.child.parent = obj. JavaScript objects can hold these cycles in memory just fine, but JSON has no syntax to represent them, which is why JSON.stringify throws when it encounters one.

Why does JSON.stringify throw on circular references?

JSON is a text-based, tree-shaped format. It has no way to express "this value is the same object as the one at path X." When JSON.stringify walks an object graph and revisits a node it has already seen, it throws TypeError: Converting circular structure to JSON because serializing it would require infinite recursion or a format feature that plain JSON does not have.

How do I stringify an object with circular references without losing data?

Use the flatted library (1.4 KB gzipped): it replaces circular references with string ID placeholders so the structure can be fully restored by flatted.parse(). If you only need one-way serialization (logging, debugging), a WeakSet replacer is simpler but replaces circular values with undefined.

Does JSON.parse ever cause circular reference errors?

No. JSON.parse reads a flat JSON string and builds a new object tree, so it can never produce a circular reference on its own. Circular reference errors come exclusively from JSON.stringify (or structuredClonewhen given a circular object to pass through a non-structured-clone channel), which must traverse an existing object graph.

How do I detect a circular reference before calling JSON.stringify?

Write a recursive function that tracks visited objects in a WeakSet. Walk every property: if an object value is already in the set, you have found a cycle. Return true early and skip calling JSON.stringify. This is O(n) in time and memory relative to the number of unique objects in the graph. Remember to backtrack (seen.delete(value)) after visiting each subtree so that shared (non-circular) references are not falsely reported as cycles.

Does structuredClone() handle circular references?

structuredClone() (built into browsers since 2022 and Node.js 17+) does correctly clone objects with circular references — the clone will contain the same cycle. However, you still cannot pass the result to JSON.stringify() without hitting the same TypeError. structuredClone() solves the in-memory deep-copy problem, not the JSON serialization problem.

Validate your JSON

After removing a circular reference and calling JSON.stringify, paste the result into Jsonic to confirm it is valid JSON before sending it to an API or writing it to a file.

Open JSON Formatter