Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Circular JSON Error: Serialize the Data, Not the Runtime Owner

Build a deliberate transfer object instead of stringifying a framework or request object.

By ON Lab2 min readSources checked November 10, 2026 (KST)

A circular JSON error can mean you are serializing an object that owns runtime relationships rather than just the data you want to transfer. Select a deliberate data object. A cycle-removing serializer is a different contract and can silently discard information.

Reproduce the boundary in a disposable fixture

Build a deliberate transfer object instead of stringifying a framework or request object.

Use a new empty temporary directory for this fixture. The example creates only local test data. Commands marked with example.invalid do not identify a real service.

const assert = require('node:assert/strict');
const state = {count:2};
const owner = {state};
owner.self = owner;
assert.throws(() => JSON.stringify(owner),TypeError);
const dto = {count:owner.state.count};
assert.equal(JSON.stringify(dto),'{"count":2}');
// A repeated reference without a cycle is valid.
const shared = {label:'same'};
const text = JSON.stringify({left:shared,right:shared});
const parsed = JSON.parse(text);
assert.deepEqual(parsed.left,parsed.right);
assert.notEqual(parsed.left,parsed.right);
console.log(text);

Observed result

{"left":{"label":"same"},"right":{"label":"same"}}

What the result means

The owner has a path back to itself and fails serialization. The transfer object contains only the intended count and succeeds. The repeated-reference example also succeeds, but the two parsed children are separate objects. This distinguishes an actual cycle from merely having the same object in two places, and shows that ordinary JSON is not a mechanism for preserving in-memory object identity.

Choose the correction for your workflow

Locate the boundary that calls JSON.stringify: an HTTP response, log field, cache entry or message payload. Inspect the expected schema and construct only those fields from the runtime object. For an HTTP handler, this often means returning a result body rather than the entire request or response wrapper. For framework state, read the documented state export interface rather than assuming every store property belongs on the wire. Validate the selected data and keep private fields out of logs. A successful stringify is necessary for that representation, but not sufficient to prove that the correct data was selected.

Limits and the next check

The fixture is plain JavaScript on the recorded Node version, not a tested fix for every Express middleware or framework version. A WeakSet-based replacer can incorrectly drop repeated non-cyclic references if it treats all seen objects as cycles. A special graph format requires a compatible decoder. util.inspect may help local diagnosis, but its output is not a substitute for a stable JSON API contract.

Related: Compare the related workflow boundary.

This isolated example was executed on macOS on November 10, 2026 (KST). Available tools: node v26.5.0, git git version 2.39.3 (Apple Git-145), npm 11.17.0, jq jq-1.7.1-apple. Shell fixtures use Bash and repository-path identity uses Python 3. This checks the illustrated contract, not your project, host service or every tool version.

Sources & further reading