JSON Circular Reference: Detect, Serialize & Remove Cycles
Last updated:
A JSON circular reference occurs when an object contains a reference to itself (directly or indirectly), causing JSON.stringify() to throw "TypeError: Converting circular structure to JSON". The most common cause in JavaScript is DOM nodes (which reference parent/child elements), Express.js req/res objects, and Mongoose documents with populated subdocuments. Node.js adds a .cause property to Error objects in v16+ that can create cycles.
This guide covers detecting circular references with a WeakSet replacer, the flatted library (npm install flatted) for lossless serialization, structured cloning for cycle-safe copies, and how to prevent cycles in data models. All examples include TypeScript types.
What Causes Circular References in JSON?
A circular reference is created any time property assignment closes a loop in the object graph. JavaScript makes this trivially easy to produce by accident — and several popular runtime environments produce cyclic objects internally. Understanding the root causes is the first step toward fixing the TypeError.
// ── Direct self-reference ─────────────────────────────────────────
const user = { name: "Alice" };
user.self = user; // user.self === user — direct cycle
JSON.stringify(user);
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'Object'
// --- property 'self' closes the circle
// ── Indirect cycle (two-hop) ──────────────────────────────────────
const parent = { name: "parent" };
const child = { name: "child", parent };
parent.child = child; // parent → child → parent
JSON.stringify(parent); // TypeError
// ── DOM nodes (browser) ───────────────────────────────────────────
const div = document.getElementById("root");
// div.parentNode.childNodes[0] === div — DOM is inherently cyclic
JSON.stringify(div); // TypeError
// ── Express req / res (Node.js) ───────────────────────────────────
app.get("/", (req, res) => {
// req._events, req.socket, res.socket all create reference cycles
JSON.stringify(req); // TypeError — never do this
res.json({ url: req.url }); // safe: extract only what you need
});
// ── Mongoose populated document ───────────────────────────────────
const post = await Post.findById(id).populate("author");
// post.__parent, post.$__ hold Mongoose internals with cycles
JSON.stringify(post); // TypeError
JSON.stringify(post.toObject()); // OK — .toObject() strips internals
// ── Node.js Error.cause (v16+) ────────────────────────────────────
const cause = new Error("root cause");
const err = new Error("outer", { cause });
// Non-cyclic here — but chaining errors with .cause can create cycles
// if cause === err (accidentally assigned)
// ── React fiber nodes (internal) ─────────────────────────────────
// React component fiber holds: return (parent), child, sibling pointers
// Never serialize a fiber node — use props/state onlyThe pattern is always the same: an object in the graph holds a reference to an ancestor, closing the loop. JSON.stringify() uses a depth-first traversal with no visited-node tracking, so it enters the cycle and eventually exhausts the call stack — throwing the TypeError before a stack overflow in V8 because V8 has a dedicated circular-structure check. Node.js wraps the native JSON.stringify with additional cycle detection that throws the descriptive error message instead of crashing. Understanding which library or runtime creates the cycle tells you the right fix: omit internal fields for Express/Mongoose, use JSON nested objects carefully for custom data models, and never serialize DOM nodes directly.
Detecting Circular References Before JSON.stringify()
Detecting a cycle before attempting serialization lets you fail fast with a meaningful error, identify the exact key that closes the loop, and choose the right remediation strategy. A WeakSet is the correct data structure for this: unlike a regular Set, a WeakSet holds weak references to objects — when the only remaining reference to an object is from the WeakSet, it is garbage-collected automatically, preventing memory leaks in long-running servers.
// ── Boolean cycle check with WeakSet ─────────────────────────────
function hasCircularReference(obj: unknown, seen = new WeakSet()): boolean {
if (obj === null || typeof obj !== "object") return false;
if (seen.has(obj as object)) return true;
seen.add(obj as object);
for (const value of Object.values(obj as object)) {
if (hasCircularReference(value, seen)) return true;
}
seen.delete(obj as object); // allow diamond references
return false;
}
const safe = { a: 1, b: { c: 2 } };
const circular = { name: "Alice" } as Record<string, unknown>;
circular.self = circular;
console.log(hasCircularReference(safe)); // false
console.log(hasCircularReference(circular)); // true
// ── Identify the exact key that closes the cycle ──────────────────
function findCircularKey(
obj: unknown,
path = "",
seen = new WeakSet()
): string | null {
if (obj === null || typeof obj !== "object") return null;
if (seen.has(obj as object)) return path || "(root)";
seen.add(obj as object);
for (const [key, value] of Object.entries(obj as object)) {
const result = findCircularKey(value, path ? `${path}.${key}` : key, seen);
if (result !== null) return result;
}
seen.delete(obj as object);
return null;
}
console.log(findCircularKey(circular)); // "self"
console.log(findCircularKey(safe)); // null
// ── Simple try/catch detection ────────────────────────────────────
function isCircular(obj: unknown): boolean {
try {
JSON.stringify(obj);
return false;
} catch (e) {
if (e instanceof TypeError && e.message.includes("circular")) return true;
throw e; // re-throw unexpected errors
}
}
// ── TypeScript: detect cycles in typed objects ────────────────────
interface User {
name: string;
friend?: User; // recursive type — can be cyclic at runtime
}
function safeStringify(obj: User): string {
if (hasCircularReference(obj)) {
throw new TypeError(`Cannot serialize: circular reference at "${findCircularKey(obj)}"`);
}
return JSON.stringify(obj);
}
// ── Check arrays of objects ───────────────────────────────────────
function hasCircularInArray(arr: unknown[]): boolean {
return arr.some((item) => hasCircularReference(item));
}The seen.delete(obj) call after processing an object's children is not optional — omitting it causes false positives for diamond references, where the same object is legitimately reachable via two different property paths without forming a cycle. For example, a shared config object referenced by two modules is not a circular reference. The try/catch approach using JSON.stringify() is the simplest detection method when you only need a boolean and do not need to know which key causes the cycle — but it performs unnecessary serialization work. The WeakSet approach is more efficient when you only need detection and the object is large.
JSON.stringify() Replacer with WeakSet for Cycle-Safe Serialization
The replacer parameter of JSON.stringify() is called for every value in the object graph, giving you a hook to substitute or omit values before they are serialized. A WeakSet replacer tracks seen objects and replaces cycles with a sentinel string or undefined (to omit the key). This produces valid JSON without throwing — the trade-off is that cyclic references are lost or replaced with a placeholder.
// ── WeakSet replacer: replace cycles with "[Circular]" ───────────
function makeCircularReplacer() {
const seen = new WeakSet();
return function (key: string, value: unknown): unknown {
if (typeof value === "object" && value !== null) {
if (seen.has(value as object)) {
return "[Circular]"; // valid JSON string — cycle marked
}
seen.add(value as object);
}
return value;
};
}
const circular = { name: "Alice" } as Record<string, unknown>;
circular.self = circular;
const json = JSON.stringify(circular, makeCircularReplacer(), 2);
console.log(json);
// {
// "name": "Alice",
// "self": "[Circular]"
// }
// ── WeakSet replacer: omit cyclic keys (undefined = omit) ─────────
function makeOmitCircularReplacer() {
const seen = new WeakSet();
return function (_key: string, value: unknown): unknown {
if (typeof value === "object" && value !== null) {
if (seen.has(value as object)) {
return undefined; // undefined → key omitted from JSON output
}
seen.add(value as object);
}
return value;
};
}
JSON.stringify(circular, makeOmitCircularReplacer(), 2);
// { "name": "Alice" } — "self" key omitted entirely
// ── Combined replacer: filter keys AND handle cycles ─────────────
function makeFilterReplacer(omitKeys: string[]) {
const seen = new WeakSet();
return function (key: string, value: unknown): unknown {
// Omit specified internal keys
if (omitKeys.includes(key)) return undefined;
// Skip cycles
if (typeof value === "object" && value !== null) {
if (seen.has(value as object)) return "[Circular]";
seen.add(value as object);
}
return value;
};
}
// Strip Express internals and handle any remaining cycles
JSON.stringify(req, makeFilterReplacer(["socket", "_events", "connection"]), 2);
// ── TypeScript: typed replacer function ──────────────────────────
type Replacer = (this: unknown, key: string, value: unknown) => unknown;
function circularReplacer(sentinel: unknown = "[Circular]"): Replacer {
const seen = new WeakSet();
return function (key: string, value: unknown): unknown {
if (typeof value === "object" && value !== null) {
if (seen.has(value as object)) return sentinel;
seen.add(value as object);
}
return value;
};
}
// Usage with null sentinel (omit) or string sentinel (mark)
const withMark = JSON.stringify(obj, circularReplacer("[Circular]"), 2);
const withOmit = JSON.stringify(obj, circularReplacer(undefined), 2);
// ── Note on replacer and the root object ─────────────────────────
// The replacer is called with key="" and value=<root object> first.
// seen.add() on the root object is correct — subsequent references
// to the root itself (not just its properties) are correctly flagged.Each call to makeCircularReplacer() creates a fresh WeakSet, so the replacer function is stateful — do not reuse the same replacer instance across multiple JSON.stringify() calls, as the seen set would carry over visited objects from the previous call and produce false positives. The replacer approach has one important limitation: it is lossy. Replacing a cycle with "[Circular]" means JSON.parse() cannot reconstruct the original cyclic structure — you get a string where the reference was. For lossless serialization of cyclic structures, use the flatted library described in the next section.
The flatted Library: Lossless Circular JSON Serialization
The flatted library (npm install flatted) solves the core limitation of the WeakSet replacer: it serializes circular references losslessly. The encoded output is valid JSON that can be stored, transferred, and parsed back to reconstruct the original object graph — including all cycles. This makes flatted the right tool when you need to transfer cyclic structures across process boundaries such as Node.js worker threads, IPC channels, or WebSocket messages.
import { stringify, parse, toJSON, fromJSON } from "flatted";
// ── Basic usage: direct self-reference ───────────────────────────
const a: Record<string, unknown> = { name: "Alice" };
a.self = a;
const encoded = stringify(a);
console.log(encoded);
// ["[1]",{"name":"[2]","self":"[0]"},"Alice"]
// [0] = root reference, [1] = root object, [2] = "Alice" string value
const decoded = parse(encoded);
console.log(decoded.name); // "Alice"
console.log(decoded.self === decoded); // true — cycle reconstructed
// ── Indirect cycle ────────────────────────────────────────────────
const parent: Record<string, unknown> = { id: 1 };
const child: Record<string, unknown> = { id: 2, parent };
parent.child = child;
const enc = stringify(parent);
const dec = parse(enc) as typeof parent;
console.log((dec.child as typeof child).parent === dec); // true
// ── Pretty-print equivalent ───────────────────────────────────────
// flatted does not support indentation natively —
// parse then re-stringify with JSON.stringify for display only
const pretty = JSON.stringify(parse(stringify(obj)), null, 2);
// ── TypeScript: typed parse ───────────────────────────────────────
interface Node {
value: number;
next?: Node; // linked list — cyclic when tail.next = head
}
const head: Node = { value: 1 };
const tail: Node = { value: 2, next: head }; // head ← tail — not cyclic yet
head.next = tail; // now cyclic: head ↔ tail
const enc2 = stringify(head);
const dec2 = parse(enc2) as Node;
console.log(dec2.value); // 1
console.log(dec2.next?.value); // 2
console.log(dec2.next?.next === dec2); // true — full cycle reconstructed
// ── toJSON / fromJSON API ─────────────────────────────────────────
// Compatible with JSON.stringify reviver/replacer conventions
const jsonSafe = toJSON(a); // converts cyclic obj to a plain JSON-safe structure
const restored = fromJSON(jsonSafe); // restores cycles
// ── flatted vs JSON.stringify size comparison ─────────────────────
const large = { data: Array.from({ length: 1000 }, (_, i) => ({ id: i })) };
const jsonSize = JSON.stringify(large).length; // ~26KB
const flattedSize = stringify(large).length; // ~27KB — slight overhead
// ── Use with structured data in worker threads ────────────────────
// worker.ts
import { stringify } from "flatted";
self.postMessage(stringify(cyclicResult)); // serialize cyclic result
// main.ts
import { parse } from "flatted";
worker.on("message", (data: string) => {
const result = parse(data); // reconstruct cyclic structure
});The flatted encoding format wraps the original object in a flat array where each unique object or string value gets an index. References that would cause cycles are replaced with their index string (e.g., "[0]"). The parse() function understands this encoding and reconstructs the original reference topology. The size overhead of flatted over standard JSON is small (typically 1–5%) for real-world objects — the encoding becomes more efficient relative to JSON as the number of shared references increases, since each shared object is stored once and referenced by index rather than duplicated. Note that circular-json, an older library with similar goals, is now deprecated — the author recommends flatted as the successor.
Removing Cycles: Breaking References Before Serialization
When you control the data model, the cleanest fix is to break the cycle before it reaches JSON.stringify(). This avoids the performance cost of a cycle-detection replacer and produces smaller, cleaner JSON output. Three patterns cover most cases: selective omission of known cyclic keys, deep cloning with cycle stripping, and converting library objects to their plain-object representations.
// ── Pattern 1: Omit known cyclic keys with destructuring ─────────
interface TreeNode {
id: number;
value: string;
parent?: TreeNode; // back-reference — causes cycle
children: TreeNode[];
}
function toSerializable(node: TreeNode): Omit<TreeNode, "parent"> & { children: ReturnType<typeof toSerializable>[] } {
const { parent: _, ...safe } = node; // drop parent back-reference
return {
...safe,
children: node.children.map(toSerializable),
};
}
JSON.stringify(toSerializable(rootNode)); // safe — no parent references
// ── Pattern 2: Whitelist-based deep clone ─────────────────────────
function pickDeep(
obj: Record<string, unknown>,
allowedKeys: string[]
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key of allowedKeys) {
if (key in obj) result[key] = obj[key];
}
return result;
}
// Serialize only known-safe fields from an Express request
const safeReq = pickDeep(req as unknown as Record<string, unknown>, [
"method", "url", "headers", "body", "params", "query",
]);
JSON.stringify(safeReq); // safe
// ── Pattern 3: Mongoose — use .toObject() or .lean() ─────────────
// .toObject() strips Mongoose document wrapper
const plainDoc = mongooseDoc.toObject({ virtuals: true });
JSON.stringify(plainDoc); // safe
// .lean() in the query — returns plain objects, no .toObject() needed
const docs = await Post.find({ published: true })
.populate("author", "name email") // populate only needed fields
.lean(); // returns plain JS objects
JSON.stringify(docs); // safe
// ── Pattern 4: structuredClone() — detect cycles, do not fix them ─
// structuredClone throws on circular references — use as a validator
function validateNoCycles<T>(obj: T): T {
try {
structuredClone(obj); // throws DataCloneError if cyclic
return obj;
} catch (e) {
throw new TypeError(`Circular reference in object: ${(e as Error).message}`);
}
}
// ── Pattern 5: Break cycles by tracking parent during construction ─
function buildTree(
nodes: Array<{ id: number; parentId: number | null; label: string }>
): Record<number, unknown> {
const map: Record<number, { id: number; label: string; children: unknown[] }> = {};
for (const n of nodes) {
map[n.id] = { id: n.id, label: n.label, children: [] };
}
for (const n of nodes) {
if (n.parentId !== null) {
(map[n.parentId].children as unknown[]).push(map[n.id]);
// NOTE: do NOT set map[n.id].parent = map[n.parentId] here
// — that would create a back-reference cycle
}
}
return map;
}
// ── Pattern 6: WeakRef for optional back-references ───────────────
// WeakRef does not prevent GC and is not serializable — JSON.stringify
// skips WeakRef values (they appear as empty objects {})
class Child {
constructor(public name: string, public parent: WeakRef<Parent>) {}
}
class Parent {
children: Child[] = [];
addChild(name: string) {
this.children.push(new Child(name, new WeakRef(this)));
}
}
// JSON.stringify(parent) — WeakRef in children appears as {} (harmless)The lean() approach for Mongoose is the most impactful performance optimization for read-only API endpoints — Mongoose documents wrap each field with getters/setters and change-tracking overhead, while lean objects are plain JavaScript objects with no overhead. Benchmarks show lean queries are 2–5x faster than full document queries for large result sets. The WeakRef pattern is a modern ES2021 approach for optional back-references: because WeakRef instances are not serializable, JSON.stringify skips them silently (they serialize as {}), which effectively breaks the cycle without any explicit replacer logic — though you lose the back-reference in the serialized output.
TypeScript: Preventing Circular Types in JSON Interfaces
TypeScript recursive types compile correctly and describe real-world tree and graph structures — but they can also represent cyclic data that cannot be serialized to JSON. Adding a separate Serializable version of each recursive type, or using JSON.stringify-compatible constraints, prevents runtime errors before they happen.
// ── Recursive TypeScript type — valid, but may be cyclic at runtime ─
interface Category {
id: number;
name: string;
parent?: Category; // back-reference — may cause JSON cycle
children: Category[];
}
// ── JSON-safe version: replace back-references with IDs ───────────
interface CategoryJSON {
id: number;
name: string;
parentId: number | null; // ID reference, not object reference
children: CategoryJSON[]; // children only — no parent object
}
function toJSON(cat: Category): CategoryJSON {
return {
id: cat.id,
name: cat.name,
parentId: cat.parent?.id ?? null,
children: cat.children.map(toJSON),
};
}
// ── Generic: strip specified keys from a type ─────────────────────
type OmitNested<T, K extends keyof T> = Omit<T, K> & {
[P in keyof T as P extends K ? never : P]: T[P] extends object
? OmitNested<T[P], K>
: T[P];
};
// ── JsonSafe utility type: mark potentially-cyclic refs as never ──
type JsonPrimitive = string | number | boolean | null;
type JsonSafe<T> = T extends JsonPrimitive
? T
: T extends Array<infer U>
? Array<JsonSafe<U>>
: T extends object
? { [K in keyof T]: JsonSafe<T[K]> }
: never;
// ── Compile-time guard: only accept serializable objects ──────────
function safeStringify<T extends JsonSafe<T>>(obj: T): string {
return JSON.stringify(obj);
}
// safeStringify(circularObj) — compile error if T contains cycles
// safeStringify({ id: 1, name: "Alice" }) — OK
// ── Preventing cycles in API response types ───────────────────────
// Anti-pattern: response type mirrors domain model including back-refs
interface PostResponse {
id: number;
title: string;
author: UserResponse; // OK — forward reference
}
interface UserResponse {
id: number;
name: string;
// posts?: PostResponse[]; // AVOID — creates bidirectional cycle in types
postIds?: number[]; // PREFER — IDs only, no back-reference
}
// ── Ajv schema for recursive JSON (not cyclic data) ───────────────
const treeSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "tree-node",
type: "object",
properties: {
id: { type: "number" },
label: { type: "string" },
children: { type: "array", items: { $ref: "#" } }, // recursive $ref
},
required: ["id", "label", "children"],
} as const;
// Ajv handles recursive $ref — validates tree depth without cyclesThe most reliable long-term prevention strategy is designing API response types that use ID references instead of object references for back-links — parentId: number instead of parent: Category. This is the same approach used by REST APIs and GraphQL (which uses field selection to prevent over-fetching cyclic graphs). The JsonSafe utility type is an advanced TypeScript pattern that catches cyclic types at compile time, though TypeScript's type system limitations mean it cannot catch all runtime cycles — objects satisfying the type constraint can still have cycles if the recursive depth is large enough to fool the type checker. See our guide on JSON data types for more on TypeScript JSON patterns.
Circular References in Popular Libraries (Express, Mongoose, React)
Most circular reference errors in production come from three libraries: Express.js (req/res objects), Mongoose (document internals), and React (fiber nodes). Each has a specific fix that avoids the cycle entirely — understanding the source of the cycle tells you exactly which properties to drop.
// ── Express.js: safe request logging ─────────────────────────────
import express, { Request, Response, NextFunction } from "express";
// WRONG — req has circular structure
app.use((req: Request, _res: Response, next: NextFunction) => {
console.log(JSON.stringify(req)); // TypeError: Converting circular structure
next();
});
// CORRECT — extract only safe fields
app.use((req: Request, _res: Response, next: NextFunction) => {
const log = {
method: req.method,
url: req.url,
path: req.path,
query: req.query,
headers: req.headers,
body: req.body, // safe if body-parser is configured
ip: req.ip,
};
console.log(JSON.stringify(log)); // OK
next();
});
// Pino structured logging — built-in req/res serializers handle cycles
import pino from "pino";
const logger = pino();
app.use((req, res, next) => {
logger.info({ req }, "incoming request"); // pino.stdSerializers.req strips cycles
next();
});
// ── Mongoose: lean queries and .toObject() ────────────────────────
import mongoose, { Schema, Document } from "mongoose";
interface IUser extends Document {
name: string;
email: string;
}
const UserSchema = new Schema<IUser>({
name: String,
email: String,
});
const User = mongoose.model<IUser>("User", UserSchema);
// WRONG — Mongoose document has circular __parent, $__ internals
const doc = await User.findById(id);
JSON.stringify(doc); // TypeError
// CORRECT option 1 — .toObject() strips Mongoose document wrapper
JSON.stringify(doc?.toObject({ virtuals: false }));
// CORRECT option 2 — .lean() returns plain objects, fastest approach
const plainDoc = await User.findById(id).lean();
JSON.stringify(plainDoc); // OK — plain object, no Mongoose internals
// CORRECT option 3 — configure schema toJSON
UserSchema.set("toJSON", {
virtuals: false,
transform: (_doc, ret) => {
delete ret.__v; // remove Mongoose version key
return ret;
},
});
// Now JSON.stringify(doc) calls doc.toJSON() automatically via the schema config
// ── React: never serialize component instances or refs ────────────
import React, { useRef } from "react";
// WRONG — ref.current is a DOM node with circular parent/child refs
const ref = useRef<HTMLDivElement>(null);
JSON.stringify(ref.current); // TypeError
// CORRECT — read only the data you need from the DOM node
const rect = ref.current?.getBoundingClientRect();
JSON.stringify({ width: rect?.width, height: rect?.height }); // OK
// CORRECT — serialize only component state/props, never the component itself
function MyComponent({ data }: { data: Record<string, unknown> }) {
// OK: data is a plain object passed as a prop
const serialized = JSON.stringify(data);
return <pre>{serialized}</pre>;
}
// ── Node.js Error.cause chains ────────────────────────────────────
function safeError(err: Error): Record<string, unknown> {
return {
message: err.message,
name: err.name,
stack: err.stack,
// Recurse into cause, but stop at depth 5 to avoid unexpected cycles
cause: err.cause instanceof Error ? safeError(err.cause as Error) : String(err.cause ?? ""),
};
}
const err = new Error("outer", { cause: new Error("inner") });
JSON.stringify(safeError(err)); // OKExpress's req object inherits from Node.js's http.IncomingMessage, which holds a reference to the underlying socket (a net.Socket), which in turn references the parser and the request object — the cycle runs through several internal Node.js classes. The safest approach for Express logging is to always project to a plain object with only the fields your logging system needs. For Mongoose, the .lean() query modifier is the canonical solution recommended in the official Mongoose documentation for read-only operations — it bypasses the Mongoose document wrapper entirely and is significantly faster. For React, the rule is simple: never serialize a component instance, a ref's current value (DOM node), or any object from React's internal fiber tree — serialize only props and state, which are plain JavaScript values. See our guide on JSON.parse() for related serialization patterns.
Key Terms
- circular reference
- A data structure where an object references itself directly (
obj.self = obj) or indirectly through a chain of properties, creating a loop in the object graph. JSON.stringify() throwsTypeError: Converting circular structure to JSONwhen it encounters one because the JSON specification only allows acyclic tree structures. A circular reference is distinct from a diamond reference, where the same object is reachable via two separate non-cyclic paths — diamond references are valid and must not be incorrectly flagged as cycles. - object graph
- The network of objects and their references in memory — a directed graph where nodes are objects and edges are property references. JSON serialization traverses this graph depth-first, converting it to a tree. When the graph contains cycles (edges that point back to ancestors), the traversal loops infinitely unless cycle detection is in place. JavaScript's garbage collector handles cyclic object graphs correctly using tracing GC — only serialization tools like JSON.stringify() fail on cycles.
- WeakSet
- A JavaScript built-in collection that stores object references without preventing garbage collection. Unlike a regular
Set, a WeakSet holds weak references — if the only remaining reference to an object is from the WeakSet, the GC can collect it and remove it from the set automatically. WeakSet is the correct data structure for cycle detection in recursive traversal because it does not leak memory even in long-running server processes that process many objects over time. WeakSet only accepts objects (not primitives), which aligns exactly with the need to track visited objects during traversal. - replacer function
- The second parameter of
JSON.stringify(value, replacer, space)— a function called for every key-value pair in the object graph during serialization. The replacer receives the current key and value, and returns the value to serialize (orundefinedto omit the key). A WeakSet replacer uses closure over a WeakSet to detect and replace or omit cyclic values. The replacer is also used for value transformation — convertingDateobjects to ISO strings, filtering sensitive fields, or convertingBigIntvalues to strings. - flatted
- A JavaScript library (npm install flatted) that provides lossless serialization and deserialization of cyclic JavaScript objects to and from valid JSON. Unlike a WeakSet replacer that replaces cycles with a sentinel string, flatted encodes the full object graph — including all cycles — as a flat array of indexed values. The
parse(stringify(obj))round-trip reconstructs the original object with all cyclic references intact. Maintained by Andrea Giammarchi as the successor to the deprecatedcircular-jsonlibrary. - lossless serialization
- Serialization that preserves all information in the original data structure, allowing perfect reconstruction on deserialization. Standard
JSON.stringify()/JSON.parse()is lossy for several JavaScript types:undefinedvalues are omitted,NaNandInfinitybecomenull,Dateobjects become strings (not re-parsed as Dates), and cyclic references cause an error. Theflattedlibrary provides lossless serialization for cyclic structures specifically — cycles are preserved as index references in the encoded array. - reference cycle
- A specific pattern in an object graph where following property references eventually leads back to a previously visited object. A direct reference cycle is
a.self = a; an indirect cycle isa.b = b; b.c = c; c.a = a. Reference cycles are valid in JavaScript's memory model and are handled correctly by the GC, but they cause infinite loops in recursive algorithms that do not track visited nodes. Cycle detection using a WeakSet or Set breaks the infinite recursion by identifying when a node has already been visited.
FAQ
What causes "TypeError: Converting circular structure to JSON"?
This error is thrown by JSON.stringify() when it encounters an object that references itself — directly (obj.self = obj) or indirectly through a chain of properties. JSON is a flat, acyclic data format: the specification does not allow circular references, so the serializer must throw rather than loop infinitely. The most common real-world causes are: DOM nodes (every element references parentNode, childNodes, and ownerDocument, creating dense reference cycles); Express.js req and res objects (the request object references the response through internal Node.js http.IncomingMessage and ServerResponse chains); Mongoose documents with populated subdocuments (the populated document references back to the parent via __parent); Node.js Error .cause chains that loop back; and React fiber nodes (the internal fiber tree holds parent/child/sibling pointers). Fix: extract only the fields you need before serializing, use a WeakSet replacer, or use the flatted library.
How do I detect if an object has circular references?
Use a WeakSet to track visited objects during traversal. A WeakSet holds weak references — objects are garbage-collected when no other references exist, preventing memory leaks in long-running processes. Detection function: function hasCircularReference(obj, seen = new WeakSet()) { if (obj !== null && typeof obj === "object") { if (seen.has(obj)) return true; seen.add(obj); for (const value of Object.values(obj)) { if (hasCircularReference(value, seen)) return true; } seen.delete(obj); } return false; }. Call seen.delete(obj) after processing children to allow diamond references (the same object reachable via two non-cyclic paths) which are not circular. Alternatively, wrap JSON.stringify() in a try/catch — if it throws TypeError with "circular structure" in the message, the object has a cycle. The try/catch is the simplest approach when you only need a boolean result and do not need to know which key causes the cycle.
How do I serialize an object with circular references to JSON?
Three approaches: (1) WeakSet replacer — pass a replacer function to JSON.stringify() that skips already-seen objects: const seen = new WeakSet(); JSON.stringify(obj, (key, value) => { if (typeof value === "object" && value !== null) { if (seen.has(value)) return "[Circular]"; seen.add(value); } return value; }). This replaces cycles with the string "[Circular]" — the output is valid JSON but loses the cyclic references. (2) flatted library (npm install flatted) — import { stringify } from "flatted"; stringify(obj). The flatted format encodes circular references as index references in a flat array, enabling lossless round-trip: parse(stringify(obj)) reconstructs the original structure including cycles. (3) Custom omit replacer — return undefined instead of "[Circular]" to drop cyclic keys entirely from the output — useful when you want clean JSON without placeholder strings.
What is the flatted library and how does it handle circular references?
flatted (npm install flatted) is a 6KB gzipped library that serializes JavaScript objects with circular references to valid JSON using a flat array encoding. Instead of a direct JSON object tree, flatted produces an array: the first element is the root structure with values replaced by index references (e.g., "[0]"), and subsequent elements are the referenced values. Example: const a = { }; a.self = a; stringify(a) produces ["[1]",{"self":"[0]"}] where "[0]" references the root. parse(stringify(a)) reconstructs the original object with the self-reference intact — this is lossless round-trip serialization. The library also provides toJSON/fromJSON APIs compatible with JSON.stringify replacer/reviver conventions. It is maintained as the official successor to the deprecated circular-json package. Use flatted when you need to transfer cyclic data structures across process boundaries (worker threads, IPC, WebSockets) and reconstruct them on the other side.
How do I remove circular references from an object before JSON.stringify()?
Three strategies: (1) Delete the cyclic properties — if you know which properties cause cycles (e.g., __parent, _events, socket), delete them: const safe = { ...obj }; delete safe.__parent; delete safe._events; JSON.stringify(safe). (2) WeakSet replacer returning undefined — undefined causes JSON.stringify to omit the key entirely: const seen = new WeakSet(); JSON.stringify(obj, (key, value) => { if (typeof value === "object" && value !== null) { if (seen.has(value)) return undefined; seen.add(value); } return value; }). (3) Use library-specific conversions — for Mongoose documents, call .toObject() or use .lean() queries; for Express req/res, project to a plain object with only the fields needed. structuredClone() (Node.js 17+) is not cycle-safe — it throws DataCloneError on cycles, making it useful as a cycle detector but not as a cycle remover.
Why does Express.js throw circular reference errors?
Express req and res objects inherit from Node.js's http.IncomingMessage and http.ServerResponse respectively. These base classes hold references to the underlying socket (net.Socket), which references the HTTP parser, which references the request/response objects — a cycle that runs through several internal Node.js classes. The _events object (from Node.js EventEmitter) and the connection/socket properties are the primary sources. The fix is to extract only the fields you need before serializing: const log = { method: req.method, url: req.url, headers: req.headers, body: req.body }. For structured logging, use Pino's built-in serializers — pino.stdSerializers.req(req) extracts the safe fields automatically. Never pass the raw req or res to JSON.stringify(), even indirectly through an object spread or { ...req }.
How do I handle circular references in Mongoose populated documents?
Mongoose documents contain internal properties (__parent, __parentArray, $__, db) that hold references back to the Mongoose connection and model internals, creating cycles when you try to serialize the document. Three solutions: (1) Call .toObject() on the document before serializing — this strips Mongoose internals and returns a plain JavaScript object: const plain = doc.toObject(); JSON.stringify(plain). (2) Use .lean() in the query — Model.find().lean() returns plain objects directly from MongoDB without Mongoose document wrapping: const docs = await Model.find().lean(); JSON.stringify(docs). Lean queries are 2–5x faster than full document queries. (3) Configure the schema's toJSON option — schema.set("toJSON", { virtuals: false }) — then JSON.stringify(doc) calls doc.toJSON() automatically via the configured transform. The lean() approach is recommended for read-only API responses.
Can JSON Schema validate circular reference detection?
JSON Schema itself cannot represent or validate circular references in data because JSON Schema instances are JSON documents and JSON does not allow cycles. However, JSON Schema definitions can be recursive using the $ref keyword pointing back to the same schema: { "type": "object", "properties": { "children": { "type": "array", "items": { "$ref": "#" } } } } — this schema recursively validates a tree structure. Ajv (Another JSON Schema Validator) handles recursive $ref schemas correctly. For cycle detection in data being validated, Ajv does not detect cycles in the data itself — it validates structure and types only. To detect cycles in data before Ajv validation, run the WeakSet detection function first: if (hasCircularReference(data)) throw new Error("Circular reference in data"); then ajv.validate(schema, data). The combination gives you both cycle safety and schema validation.
Further reading and primary sources
- flatted npm package — Official npm page for flatted — lossless circular JSON serialization by Andrea Giammarchi
- MDN: JSON.stringify() replacer parameter — MDN documentation on the replacer function — the hook used for WeakSet cycle detection
- MDN: WeakSet — MDN reference for WeakSet — the correct data structure for cycle detection without memory leaks
- Mongoose lean() documentation — Official Mongoose guide to lean queries — the recommended fix for Mongoose circular reference errors
- Pino stdSerializers — Pino standard serializers for req/res — safely extract Express request fields for structured logging