← Debugging Guides

阅读中文版本

Node.js — TypeError: Converting Circular Structure to JSON

Diagnose Converting circular structure to JSON when stringifying objects with cycles, find the back-reference in request or ORM objects, and serialize a safe projection.

beginnerdebuggingnodejsjsonserialization

Symptoms

  • TypeError: Converting circular structure to JSON appears when logging or responding with a nested object
  • The failure shows up only for certain entities, for example ones that have a parent or sibling relation loaded
  • The stack points at JSON.stringify inside a logger or res.json call, not at application logic

Possible causes

  • An ORM entity with bidirectional relations (order.customer and customer.orders) is passed to JSON.stringify
  • A request or response object was attached to a child property during request context plumbing
  • A tree structure stores a parent pointer on every node (node.parent) alongside children (node.children)
  • A cache stores the object and later links back to the cache entry itself

Diagnosis steps

Locate the exact cycle with a safe stringifier

node -e "const s = require('util').inspect(obj, { depth: 3 }); console.log(s.slice(0, 2000))"

Interpretation

util.inspect renders circular references as [Circular *1] markers, so the first marker sits next to the property that closes the loop.

Trace which property carries the back-pointer

console.log(Object.keys(order.customer)) # look for a key that returns the original object

Interpretation

A key whose value is the outer object itself is the back-reference that stringify cannot follow.

Confirm which call site serializes it

NODE_OPTIONS=--stack-trace-limit=50 node server.js # then read the stringify frame

Interpretation

The frame tells you whether a logger, res.json, or a queue payload is the consumer that must receive a projection instead.

Root cause

JSON is a tree format, so JSON.stringify traverses objects depth-first and cannot encode a node that points back to an ancestor. Any back-reference in the object graph turns into this TypeError at the moment of serialization, regardless of how correct the data itself is.

Fix

Serialize an explicit projection of the fields consumers need instead of whole ORM or request objects. Where a generic safe path is required, pass a replacer that skips known back-reference keys, or use a serializer that emits [Circular] markers for diagnostics only.

Risk note

A blanket replacer that silently drops cycles can hide data-model problems and leak unintended fields into responses. Prefer explicit projections for API payloads.

Verify the fix

Re-run the failing request with the projection in place and assert a 200 with the expected JSON body, plus a logger smoke test that prints the entity without throwing.

Symptom walkthrough

The error fires at the serialization boundary, and the message names the crime precisely:

TypeError: Converting circular structure to JSON
    at JSON.stringify (<anonymous>)
    at toPayload (src/routes/orders.js:58:17)

The object itself is fine; only its shape is unserializable. Print it with util.inspect, which annotates the loop instead of throwing:

{ id: 7, customer: { name: 'Ada', orders: [ [Circular *1] ] } }

[Circular *1] sits exactly on the property that points back to the ancestor, which is usually a bidirectional ORM relation or a parent pointer.

Fix in practice

For API responses, serialize a projection so the contract is explicit:

app.get('/orders/:id', async (req, res) => {
  const order = await db.order.findById(req.params.id, { withCustomer: true });
  res.json({
    id: order.id,
    customer: { name: order.customer.name }
  });
});

For debug logging where you want the whole graph, use inspect or a replacer that cuts known back-edges:

const SAFE_KEYS = new Set(['parent', '_request', 'customer', 'owner']);
const safe = JSON.stringify(obj, (k, v) => (SAFE_KEYS.has(k) ? '[skipped]' : v), 2);

Never send replacer-stripped output to clients; the dropping rules are for humans reading logs, not for a stable API contract.

Verification

Request the previously failing entity and assert the response is 200 with exactly the projected fields. Run the logger over an entity with loaded relations and confirm no throw. Finally, add a guard in tests: deep-scan serialized fixtures for Circular markers so a new relation added to the model cannot re-arm the failure unnoticed.