skip to content

A service logs incoming requests and one day throws "TypeError: Converting circular structure to JSON". What exactly causes JSON.stringify to throw this, and how do you make such an object serializable?

level: seniorimportance: should knowfreq 48%

answer

  1. the format is a tree, the value is a graph
  2. depth-first walk with an ancestor stack
  3. repeats are fine, ancestors are not
  4. log the fields you want, not the object
  5. a seen-set trades precision for safety

basics

~20 s

JSON.stringify walks the value as a tree, so when an object is reachable from itself along the current path it would recurse forever and the algorithm throws a TypeError instead. Fix it by serializing an explicit subset of fields, or with a replacer that tracks ancestors.

solid answer

~50 s

JSON is a tree format and serialization is a depth-first walk, so a value that contains itself somewhere below itself has no finite text form. The algorithm keeps a stack of the objects on the current path and throws a `TypeError` the moment it meets one it is already inside. Crucially it is the **path** that matters, not repetition: a diamond where two properties point at the same third object serializes fine, twice. Request, socket, error and framework objects are the usual culprits because they hold back-references to their parents. The robust fix is to stop serializing the whole graph and build an explicit, small object with the fields you actually want in the log. If you genuinely need a generic dump, use a replacer that tracks seen objects and substitutes a marker such as `'[Circular]'` — accepting that a naive seen-set also collapses harmless shared references.

code

javascript · 11 lines
javascript
const shared = { id: 1 };
console.log(JSON.stringify({ a: shared, b: shared }));
// {"a":{"id":1},"b":{"id":1}}   <- repetition is fine, just duplicated

const node = { name: 'root' };
node.self = node;
try {
  JSON.stringify(node);
} catch (err) {
  console.log(err.constructor.name); // TypeError
}

go deeper

for a junior

Recognise the message and know what it means: something in the object points back to itself, and the format cannot express that. Log a few chosen fields rather than the whole object.

for a middle

Explain the depth-first walk with an ancestor stack, and be precise that repetition is legal while an ancestor reference is not. Show the shared-object example that serializes fine.

for a senior

Diagnose it in production: read the property path in the error, know why request and framework objects have back-references, and choose an explicit field allowlist over a generic dump for size, stability and disclosure reasons.

for a principal

Own the logging contract — structured, bounded, schema-stable event payloads produced by an explicit serializer, so that no call site can ever hand an arbitrary runtime graph to the log pipeline in the first place.

## What the algorithm actually does Serialization is a depth-first traversal that emits text as it descends. To detect the impossible case, it maintains a stack of the objects currently being serialized. Before descending into an object or array it checks whether that value is already on the stack; if it is, the walk has re-entered something it never left, so no finite output exists, and the specification requires a `TypeError`. Engines add a helpful message — V8 says `Converting circular structure to JSON` and, in recent versions, prints the property path and the two points where the cycle closes. ```js const node = { name: 'root' }; node.self = node; JSON.stringify(node); // TypeError ``` ## Cycles, not duplicates The distinction that separates people who have read the algorithm from people who have guessed: the check is against the **current ancestor path**, and the object is popped off that stack when its subtree finishes. So a shared reference that is not an ancestor is perfectly legal: ```js const shared = { id: 1 }; JSON.stringify({ a: shared, b: shared }); // '{"a":{"id":1},"b":{"id":1}}' ``` The object is written out twice. That is a size problem and it means the parsed result has two distinct objects where you had one, but it is not an error. Only reachability from a node back to itself throws. ## Why real objects have cycles Almost every rich runtime object graph does: - A server request object holds a reference to its socket, and the socket holds the request. - A parsed tree node holds its children and each child holds a `parent`. - A framework's context object holds the request, which holds the context. - A cached entry holds the collection it lives in. - Two domain entities model a bidirectional relationship — an order holds its customer, the customer holds their orders. This is why the error so often appears in logging code. Nobody set out to serialize a socket; somebody logged `req` "just in case" and the object graph did the rest. ## Fix one: stop serializing the graph The first question to ask is what you actually wanted in that text. For a log line it is a handful of fields: ```js log(JSON.stringify({ method: req.method, path: req.url, requestId: req.id, userId: req.user?.id, })); ``` This is better on every axis: it cannot throw, its size is bounded, it does not leak headers or credentials that happened to be hanging off the object, and the output shape is stable enough to query. An allowlist replacer (`JSON.stringify(req, ['method', 'url'])`) expresses the same intent when the fields are top-level. ## Fix two: a cycle-tolerant replacer When you genuinely need a generic dump — a debug endpoint, a crash reporter — track what you have seen: ```js function safeStringify(value, space) { 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); } ``` This works, and it is worth being honest about what it changes. A `WeakSet` that is never pruned is **stricter than the specification**: it marks any repeated object, including the harmless diamond above, so shared-but-acyclic references also become `'[Circular]'`. Matching the real semantics requires tracking the ancestor path and removing entries as the walk unwinds, which the replacer's top-down callback cannot do on its own — it is never told when a subtree ends. For debug output the stricter behaviour is usually fine, and arguably desirable, since it also bounds the output size. Note also that the substitute changes the value's *type* from object to string, so a consumer with a schema will reject it. ## Fix three: use a structural clone where that is the real intent If what you wanted was a deep copy rather than text, `structuredClone` handles cycles natively and preserves them in the copy, along with several types that serialization cannot carry. It is not an interchange format and produces no text, so it does not help you send anything — but a surprising number of "circular structure" errors come from code that was using stringify/parse as a deep-copy idiom in the first place. ## Diagnosing it in production The stack trace points at your logging call, not at the cycle. In a V8 runtime, read the error message itself: it names the property path from the root to the offending reference. If the message is not detailed enough, reproduce with the `safeStringify` above and look for where `'[Circular]'` appears — that is the closing edge. Then apply fix one to that call site, because a generic dump of a runtime object is nearly always the wrong thing to log even when it does not throw.

  • Does an object appearing twice in a value always cause this error?
    No. The check is against the objects on the current ancestor path, and each is popped when its subtree finishes. A diamond — two properties pointing at the same third object — serializes successfully and simply writes that object out twice. Only a reference from a node back to something it is currently inside has no finite text form, and only that throws.
  • What does the WeakSet-based replacer get wrong compared to the real algorithm?
    It is stricter. Because nothing is ever removed from the set, any repeated object is reported as circular, including harmless shared-but-acyclic references. Matching the specification would require pruning entries as the walk unwinds, which a replacer cannot do since it is never notified that a subtree has ended. For debug dumps the over-strictness is usually acceptable and helpfully bounds output size.
  • Why is dumping a whole request object into a log a bad idea even when it does not throw?
    Size and disclosure. A runtime object graph pulls in headers, cookies, buffers and connection state, so a single line can be enormous and can carry credentials into a log store with different access rules. The output shape is also unstable across library versions, which breaks anything querying those logs. Serialize an explicit small set of fields instead.
  • If the goal was a deep copy rather than text, what should be used instead?
    `structuredClone`, which handles cycles natively and preserves them in the copy, and carries several types that a text round-trip cannot. Many circular-structure errors come from code using stringify-then-parse as a deep-copy idiom. It is not an interchange format and produces no text, so it only helps when copying — not when sending.

It is like photocopying a folder that contains a note saying "see this folder": following the reference literally never ends, so the machine stops and tells you rather than filling the room with paper.

saying these in an interview costs you the question

  • Says any duplicate object reference causes the error
  • Thinks the engine could handle cycles if it tried harder
  • Reaches for a try/catch and logs nothing instead of fixing the payload
  • Claims the seen-set replacer reproduces the specified behaviour exactly
  • Blames deep nesting or object size rather than a self-reference

context