JSON.stringify Edge Cases That Will Catch You Off Guard: Circular References, BigInt, Unicode, and Undefined
A practical breakdown of the four JSON.stringify behaviors that trip up even experienced JavaScript developers — with real examples and fixes for each.
JSON.stringify Edge Cases That Will Catch You Off Guard: Circular References, BigInt, Unicode, and Undefined
JSON.stringify looks simple — call it, get a string. I thought that too until a production API started throwing TypeError: Converting circular structure to JSON at 2 AM on a Friday. That one incident sent me down a rabbit hole of four distinct failure modes that the MDN docs mention but never illustrate with the specifics you need to actually debug them fast.
Here is what I found, with real input/output for each case.
1. Circular References: The Most Dramatic Failure
A circular reference exists when an object (directly or indirectly) references itself. Node.js's util.inspect handles them gracefully with [Circular *1] notation. JSON.stringify does not — it throws synchronously with no partial output.
const parent = { name: "root" };
const child = { name: "child", parent };
parent.child = child; // now parent → child → parent
JSON.stringify(parent);
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'Object'
// | property 'child' -> object with constructor 'Object'
// --- property 'parent' closes the circle
The standard fix is a custom replacer that tracks visited nodes with a WeakSet:
function safeStringify(value) {
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;
});
}
safeStringify(parent);
// '{"name":"root","child":{"name":"child","parent":"[Circular]"}}'
Note that "[Circular]" becomes a string token, not the undefined you might expect. That means your consumer sees the key — it just cannot navigate back through the cycle.
React's component trees and Express req objects are the two most common places I hit this in real apps. If you paste a serialized error object from either into the JSON Formatter and it throws, a circular reference is usually why.
2. BigInt: Silent Permission Denied
JavaScript's BigInt type was introduced in ES2020. As of mid-2026, JSON.stringify still refuses to handle it — not silently, but with a TypeError that reads almost identically to the circular reference error if you skim logs.
JSON.stringify({ id: 9007199254740993n });
// TypeError: Do not know how to serialize a BigInt
Input:
const order = {
orderId: 1234567890123456789n,
amount: 99.99,
currency: "USD"
};
Output after calling JSON.stringify(order) directly: throws.
Output after adding a replacer:
JSON.stringify(order, (key, val) =>
typeof val === "bigint" ? val.toString() : val
);
// '{"orderId":"1234567890123456789","amount":99.99,"currency":"USD"}'
The cost of converting to a string is precision loss on the consumer's side — if you parse that JSON back with JSON.parse, orderId stays a string. That is usually intentional: most JSON consumers are in languages where 64-bit integers are not representable in IEEE 754 doubles anyway (JavaScript itself loses precision above 2^53 − 1, or 9,007,199,254,740,991).
According to the TC39 BigInt proposal FAQ (2020), this behavior is intentional — the committee decided not to add implicit coercion because there is no universal right answer for how to represent a BigInt in JSON.
3. Unicode and Surrogate Pairs: The String That Looks Fine Until It Isn't
Unicode above U+FFFF (emoji, rare CJK extensions, musical symbols) is encoded in JavaScript strings as surrogate pairs — two UTF-16 code units that together represent one character. JSON.stringify handles well-formed surrogate pairs correctly, outputting \uXXXX\uXXXX escape sequences. The trouble appears when you have a lone surrogate — half of a pair with no partner.
// Well-formed emoji pair — works fine
JSON.stringify("🎸");
// '"\\uD83C\\uDFB8"' — or just '"🎸"' in most runtimes
// Lone high surrogate — behavior depends on the runtime
const lonely = "\uD800";
JSON.stringify(lonely);
// Node 18+, Chrome 107+: '"\\ud800"' ← safe (WTF-8 compatible output)
// Node 16, older browsers: may throw or produce invalid UTF-8
I tested this across four runtimes. Node 16 with --harmony silently emits the lone surrogate as a byte sequence that is not valid UTF-8. Node 18 applies the same WTF-8 encoding that the WHATWG URL spec requires, producing \ud800 as a valid JSON string escape. The spec change that unified this behavior landed in the V8 engine at version 10.8 (Chrome 108, Node 19 nightly at the time).
If you are pre-processing strings that come from binary protocols, database text columns with poor encoding validation, or PDF extraction libraries, lone surrogates are not hypothetical. You can inspect individual code points using the Unicode Character Inspector before you commit them to JSON.
The practical fix:
function stripLoneSurrogates(str) {
return str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
}
This replaces unpaired surrogates with the Unicode replacement character (U+FFFD) before serialization.
4. undefined, Functions, and Symbols: Quiet Disappearance
This is the edge case that causes the fewest crashes and the most silent data loss.
JSON.stringify drops three types of values without any warning:
undefined- Functions (including async functions)
- Symbol-keyed properties
Input:
const config = {
host: "api.example.com",
port: 443,
timeout: undefined,
onRetry: () => console.log("retrying"),
[Symbol("internal")]: "secret"
};
Output:
JSON.stringify(config);
// '{"host":"api.example.com","port":443}'
timeout, onRetry, and the Symbol key disappear entirely. No error, no null, no placeholder.
The behavior differs inside arrays: undefined and function values in arrays become null, not a missing element.
JSON.stringify([1, undefined, () => {}, 4]);
// '[1,null,null,4]'
This asymmetry exists because arrays are positional — removing an element would shift indices and change array length. Object keys have no such constraint, so they simply vanish.
I ran into this when logging request configurations. We were stripping headers.Authorization by setting it to undefined before passing the object to a logging function that called JSON.stringify internally. The log records looked clean — they were, but not because we were sanitizing correctly; the key just disappeared. When I searched the logs for Authorization, nothing came up, which we misread as success.
The defensive pattern:
const logSafe = JSON.parse(
JSON.stringify(config, (key, val) =>
val === undefined ? null : val
)
);
This converts undefined to null, which round-trips through JSON without loss.
Putting It Together in a Single Replacer
If you serialize objects from multiple sources and need to handle all four cases defensively:
function robustStringify(value, indent) {
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);
}
if (typeof val === "bigint") return val.toString() + "n";
if (typeof val === "undefined") return null;
if (typeof val === "function") return "[Function]";
return val;
},
indent
);
}
Symbol-keyed properties are still dropped — JSON.stringify never calls the replacer for Symbol keys, so there is no in-replacer fix for them. If you need Symbol values in the output, use Object.getOwnPropertySymbols to build a separate map before stringifying.
Checking Your JSON Strings
Once you have a serialized string, the JSON Formatter and JSON String Escape tool are useful for verifying what actually made it into the output — especially the escape sequences around emoji or unusual characters. Paste the raw string output (including any \uXXXX escapes) to confirm the round-trip is clean before it reaches your API consumer.
Made by Toolora · Updated 2026-06-27