JSON Circular Reference Error: How to Find and Fix It

Quick answer

💡When JSON.stringify throws 'TypeError: Converting circular structure to JSON', the object graph contains a cycle. Fix it by serializing a plain data transfer object instead of the full model instance, replacing back-references with IDs, or using a WeakSet-based replacer for debug logging. Never serialize Express req, res, or Mongoose document objects directly.

Error symptoms

  • TypeError: Converting circular structure to JSON at JSON.stringify
  • TypeError: cyclic object value (Firefox equivalent of the same error)
  • Converting circular structure to JSON --> starting at object with constructor 'Object' --- property 'self' closes the circle
  • JSON.stringify returns undefined silently in some older engines instead of throwing
  • res.json() or JSON.stringify(req.body) throws inside an Express route handler
  • Mongoose populated document throws circular reference error when returned directly from an API route

Common causes

  • An object directly assigns itself to one of its own properties, creating a self-referencing cycle
  • A parent object stores references to child objects and each child stores a back-reference to its parent
  • Express request or response objects are passed to JSON.stringify for logging or debugging
  • Mongoose documents returned from populate() contain bidirectional relationships between models
  • DOM nodes contain circular references via parentNode, childNodes, and ownerDocument properties
  • Application state objects store navigation history or undo stacks that reference earlier states

When it happens

  • Implementing debug logging that attempts to stringify a Node.js request or response object
  • Returning a Mongoose or Sequelize model instance directly from an Express route as the JSON response
  • Serializing a React component tree or Redux store state that contains non-serializable values
  • Building a graph or tree data structure in memory and then attempting to serialize the entire structure

Examples and fixes

A debug logging utility needs to serialize arbitrary objects for inspection without crashing when circular references are present. The wrong version crashes. The fixed version uses a WeakSet to detect visited objects.

Detecting and breaking a circular reference with a WeakSet replacer

❌ Wrong

function logRequestDetails(req) {
  // Throws: Converting circular structure to JSON
  const requestSnapshot = JSON.stringify(req, null, 2);
  console.log('Incoming request:', requestSnapshot);
}

// req contains circular references:
// req.socket.request === req
// req.app.request === req

✅ Fixed

function getCircularReplacer() {
  const visitedObjects = new WeakSet();
  return function replacer(key, value) {
    if (typeof value === 'object' && value !== null) {
      if (visitedObjects.has(value)) {
        return '[Circular Reference]';
      }
      visitedObjects.add(value);
    }
    return value;
  };
}

function logRequestDetails(req) {
  const safeSnapshot = JSON.stringify(
    { method: req.method, url: req.url, headers: req.headers },
    getCircularReplacer(),
    2
  );
  console.log('Incoming request:', safeSnapshot);
}

The fixed version avoids trying to stringify the entire req object, which contains circular references through socket.request and app.request. Instead it extracts only the specific fields needed for logging. The WeakSet-based replacer is demonstrated for cases where you must serialize a potentially circular structure, but the better practice is always to serialize only a deliberate subset of fields rather than passing framework objects directly.

An API route returns a populated Mongoose document. The populated document contains circular references between the user and their posts. The wrong version sends the document directly. The fixed version uses toObject() and structures the response deliberately.

Serializing a Mongoose document without circular reference errors

❌ Wrong

// GET /api/users/:id — crashes with circular reference
app.get('/api/users/:id', async (req, res) => {
  const userDocument = await User.findById(req.params.id)
    .populate('posts');

  // Mongoose documents have circular refs after populate
  res.json(userDocument);
});

✅ Fixed

// GET /api/users/:id — returns clean serializable data
app.get('/api/users/:id', async (req, res) => {
  const userDocument = await User.findById(req.params.id)
    .populate('posts')
    .lean(); // Returns a plain JS object, not a Mongoose Document

  if (!userDocument) {
    return res.status(404).json({ error: 'User not found' });
  }

  const userResponse = {
    id: userDocument._id,
    email: userDocument.email,
    displayName: userDocument.displayName,
    posts: userDocument.posts.map(post => ({
      id: post._id,
      title: post.title,
      publishedAt: post.publishedAt,
      authorId: userDocument._id
    }))
  };

  res.json(userResponse);
});

Mongoose Document instances contain internal references to the Mongoose model, schema, and parent document that create circular structures. The .lean() query modifier returns a plain JavaScript object instead of a Mongoose Document, which eliminates the internal circular references. The response is then shaped explicitly to include only the fields the client needs, with post.authorId as an ID reference instead of the full populated user object, which would recreate the cycle.

Object graph cycles that stringify cannot traverse

JSON.stringify performs a depth-first traversal of the value it receives. It visits each property of an object, recursively visits the value of each property, and builds the JSON string representation as it goes. This traversal works correctly for tree-shaped data where each object is only reachable through one path from the root. When an object can be reached through two or more paths — in particular when an object contains a path back to an ancestor — the traversal enters an infinite loop.

To prevent an actual infinite loop, JavaScript's JSON.stringify implementation detects when it is about to visit an object that is already on the current traversal stack. When it detects this, it throws a TypeError with the message 'Converting circular structure to JSON' rather than looping forever. The error message in V8, which powers Node.js and Chrome, also describes the cycle by identifying the constructor name of the starting object and the property name that closes the circle.

The most common source of circular references in Node.js applications is passing framework objects to JSON.stringify. Express request objects have circular references through properties like req.socket.request, req.app.request, and req.connection.parser. These circular references exist because the HTTP stack needs bidirectional navigation between the components that handle a request. Response objects have similar internal cycles. None of these objects are designed to be serialized to JSON; they are runtime handles for the request lifecycle.

Database ORM objects create circular references when they implement bidirectional associations. A User model instance that has many Posts, where each Post has a reference back to its User, creates a user.posts[0].user.posts[0].user chain. Mongoose, Sequelize, Prisma, and TypeORM all implement associations and relations, and populated or eagerly-loaded instances often contain these cycles. The solution in all cases is the same: serialize a deliberately constructed plain object rather than the ORM model instance itself.

Locating the circular path with util.inspect

When JSON.stringify throws a circular reference error, the error message in modern V8 engines is fairly informative. It names the object constructor and the property path that forms the cycle. A message like 'Converting circular structure to JSON --> starting at object with constructor Object --- property self closes the circle' tells you that an object of type Object has a property named self that points back to itself. For more complex cycles, V8 describes the chain of properties traversed before closing the circle.

The Node.js util.inspect function is the primary tool for examining circular structures without crashing. Unlike JSON.stringify, util.inspect handles circular references by printing [Circular *1] where it encounters an already-visited object and adding an asterisk annotation to the first occurrence of that object. Calling util.inspect(problematicObject, { depth: 4, breakLength: 100 }) produces a readable representation of the object with circular references marked. Limiting the depth prevents the output from becoming too long for complex objects.

For Mongoose documents specifically, calling document.inspect() or logging the document without JSON.stringify often produces useful output because Mongoose overrides the toString and inspect methods to handle the document structure. Calling document.toObject() or using the .lean() option on a query converts the Mongoose Document to a plain JavaScript object. However, populated references may still contain nested Mongoose Document instances, so calling toObject({ depthLimit: 5 }) is sometimes necessary for deeply nested populated data.

For debugging circular references in data structures you built yourself, the best approach is to add logging at the point where you create the circular link. Instead of discovering the cycle at serialization time, catch it at construction time with an assertion. If you are building a tree where each node holds a parent reference, verify during node creation that the new parent reference does not point to the node itself or to any descendant of the node. Catching the cycle at construction time produces a more informative error with a stack trace pointing to where the cycle was created.

WeakSet replacer for safe circular serialization

The WeakSet-based replacer function is the most common fix for serializing structures that may contain circular references, particularly for logging and debugging scenarios. The replacer function is the second argument to JSON.stringify. When provided, JSON.stringify calls the replacer with each key-value pair it encounters during traversal. The replacer can return a modified value or return undefined to exclude the property from the output.

A WeakSet-based replacer works by tracking which objects have already been visited during the current stringify call. When the replacer receives an object value, it checks whether that object is in the WeakSet. If it is, the object has already been visited and the current occurrence is part of a cycle, so the replacer returns a placeholder string such as '[Circular Reference]' instead of the object. If the object has not been visited, the replacer adds it to the WeakSet and returns the value unchanged, allowing JSON.stringify to continue its normal traversal.

Using a WeakSet rather than a regular Set or an array is important for correctness and performance. A WeakSet holds weak references to objects, which means it does not prevent garbage collection of objects that are no longer referenced elsewhere. More importantly, WeakSet uses reference identity for membership checks, not value equality. This ensures that two different objects with identical content are treated as distinct entries, which is the correct behavior for cycle detection in an object graph.

The WeakSet replacer is appropriate for debug logging and error reporting but is generally not appropriate for API responses sent to clients. A client receiving '[Circular Reference]' strings in a JSON response would need to know about this encoding to handle it correctly, which breaks the expected JSON contract. For API responses, the correct approach is to serialize a plain data transfer object that contains only the fields the client needs, with back-references replaced by ID values. This produces a clean JSON output with no placeholder strings and no information about the internal structure of the server-side data model.

structuredClone versus flatted for cycle handling

JavaScript's native structuredClone function, introduced in Node.js 17 and modern browsers, provides a way to deep-clone an object using the structured clone algorithm. Unlike JSON.stringify followed by JSON.parse, structuredClone handles circular references correctly by preserving the reference topology in the cloned result. If the original object has a property pointing to itself, the clone will also have a property pointing to its cloned self. The clone is a separate object tree with the same reference structure as the original.

However, structuredClone cannot be used as a substitute for JSON serialization when the goal is to produce a string. structuredClone produces a new in-memory object, not a string. If the cloned object is then passed to JSON.stringify, it will fail with the same circular reference error because the circular structure has been preserved in the clone. structuredClone is useful for cloning data before transforming it to remove cycles, not as a direct replacement for JSON.stringify in circular reference scenarios.

The flatted npm package provides an alternative serialization format that supports circular references by encoding them as special string tokens. When you call flatted.stringify on a circular object, it produces a JSON-like string where repeated or circular references are replaced by index strings that refer to earlier positions in the serialized structure. The flatted.parse function decodes this format back into a JavaScript object with the circular references restored. This round-trip fidelity is useful when you need to serialize and deserialize an object graph including its reference structure.

The trade-off with flatted is that its output is not standard JSON. Any consumer of the serialized data must use flatted.parse to decode it; standard JSON.parse will not work. This makes flatted suitable for internal serialization such as caching, inter-process communication, or persistence within a system that you control end-to-end. It is not appropriate for public API responses where clients expect standard JSON, because clients would need to know about flatted's encoding scheme.

Mongoose populate creating document cycles

Mongoose's populate feature is one of the most common sources of circular reference errors in Node.js APIs. When you call User.findById(id).populate('posts'), Mongoose fetches the related Post documents and substitutes them into the posts array of the User document. If the Post schema has a reference back to the User, such as an author field, and that field is also populated, each Post document contains a reference to the User document, which contains the Post document, completing the cycle.

The .lean() option is the most effective prevention. Adding .lean() to a Mongoose query returns plain JavaScript objects instead of Mongoose Document instances. Plain objects do not have the internal Mongoose metadata that contributes to cycles, and they do not have the virtual getter and setter properties that Mongoose Documents use to manage relations. The lean output also serializes significantly faster because JSON.stringify does not need to call getters or process schema validation.

When you cannot use .lean() — for example, when you need to call Mongoose instance methods on the document after fetching — use toObject() to convert the document to a plain object before serializing. The call document.toObject() returns a plain JavaScript object representing the document's fields. Populated sub-documents are also converted. However, if you have deeply nested populations, you may need to call toObject() recursively or use toObject({ depthLimit: N }) to control the conversion depth.

A structural solution is to design your API response objects explicitly at the route level, regardless of what the ORM returns. Even when using .lean() or toObject(), building an explicit response object that picks the specific fields the client needs is better practice than serializing the entire document. This approach is resilient to schema changes: adding a new field to the Mongoose schema does not automatically expose it in the API response until you explicitly add it to the response object. It also makes the API contract visible in the route handler code.

Auditing data models for circular dependencies

Preventing circular reference errors at serialization time is easier than debugging them when they occur in production. Auditing your data models for potential cycles before they manifest as errors requires understanding which objects have references to which other objects and whether any reference chain can loop back to its starting point.

For object-oriented applications, the most common cycle pattern is bidirectional associations. A parent that holds references to children, where each child holds a reference to its parent, is the canonical example. The fix is not to remove the back-reference from the runtime data model, because it may be needed for efficient traversal, but to ensure that back-references are never included in serialized output. Define a separate response type for each API endpoint that includes only the fields needed by the client, converting back-references to ID fields.

In TypeScript applications, defining explicit response types and DTO classes enforces the serialization contract at compile time. A UserResponse type that contains posts as PostSummary[] where PostSummary has authorId: string instead of author: UserResponse makes it structurally impossible to introduce a circular reference in the serialized output. The TypeScript compiler prevents you from accidentally including a full UserResponse inside a PostSummary.

For testing, adding a JSON serialization assertion to unit tests that cover data retrieval functions catches circular reference errors before they reach production. A test that calls JSON.stringify on the output of every route handler and verifies it does not throw will detect new circular references introduced by schema changes or ORM relation additions. This assertion is lightweight and can be added to existing tests without significant test code changes. Combining this with schema validation against the expected response type produces tests that verify both serializability and correct structure.

Quick fix checklist

  • Never pass Express req, res, socket, or app objects to JSON.stringify
  • Use .lean() on Mongoose queries to get plain objects instead of Document instances
  • Build explicit response DTOs with only the client-facing fields instead of serializing full model instances
  • Replace object back-references with ID fields such as parentId or authorId in API responses
  • Use the WeakSet replacer pattern only for debug logging, not for API responses
  • Use util.inspect to examine circular structures without crashing during debugging
  • Consider flatted for internal serialization that must preserve circular references across process boundaries
  • Add JSON.stringify assertions in unit tests to catch new circular references introduced by schema changes

Related guides

Frequently asked questions

What does TypeError: Converting circular structure to JSON mean?

It means JSON.stringify encountered an object that contains a reference back to an object already being traversed. JSON cannot represent reference cycles because its grammar only supports tree-shaped data. The serializer detects the cycle and throws instead of looping infinitely.

How do I fix a circular reference in a Mongoose document?

Add .lean() to your Mongoose query to receive a plain JavaScript object instead of a Mongoose Document instance. Then build an explicit response object that picks only the fields the client needs, replacing any back-references with ID values. Avoid serializing populated sub-documents that reference their parent.

Why does JSON.stringify crash when I try to log req in Express?

Express request objects contain internal circular references through req.socket.request and req.app.request. They are runtime handles, not data objects, and are not designed to be serialized. Instead, log only specific fields: { method: req.method, url: req.url, headers: req.headers, body: req.body }.

What is a WeakSet replacer and when should I use it?

A WeakSet replacer is a function passed as the second argument to JSON.stringify that uses a WeakSet to track visited objects. When it encounters an already-visited object, it returns a placeholder string instead of causing infinite recursion. Use it for debug logging only, not for API responses, because clients receiving placeholder strings would need special handling.

Can structuredClone handle circular references?

Yes, structuredClone preserves circular references in the cloned object, maintaining the same reference topology. However, it produces a new in-memory object, not a string. If you then pass the clone to JSON.stringify, it will still fail because the circular structure is preserved. Use structuredClone to clone data before removing cycles, not as a replacement for JSON serialization.

What is the flatted package and when should I use it?

flatted is an npm package that serializes circular structures by encoding repeated references as index strings. flatted.stringify produces a compact string that flatted.parse can decode back into the original object graph including cycles. Use it for internal serialization between services or for caching. Do not use it for public API responses because standard JSON clients cannot decode it.

How do I find which property in my object creates the cycle?

Use util.inspect from Node.js with a limited depth. It marks circular references with [Circular *1] and shows the path to each one. Look for properties named parent, owner, context, previous, next, cache, or request. In Mongoose documents, author, user, and creator fields that are populated with the full document often create cycles.

Is there a way to prevent circular reference errors at the data model level?

Yes. Define separate response types or DTO classes that include only serializable fields. Replace object references with ID strings in response types. In TypeScript, this makes circular response types a compile-time error. Add JSON.stringify assertions in unit tests to catch new cycles introduced by schema or relation changes before they reach production.

All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-05.