Home / Articles / What JSON.stringify Silently Drops, Converts and Refuses to Serialize

This article is published in English.

What JSON.stringify Silently Drops, Converts and Refuses to Serialize

Learn which JavaScript values JSON.stringify omits or changes, how toJSON, replacers and revivers fix it, and when structuredClone is the better tool.

1602 words

JSON.stringify rarely complains. When it meets a value it cannot represent, it usually omits it or converts it into something else and carries on, returning perfectly valid JSON. That is how bugs appear several steps away from their cause: the data disappears during serialization, but the failure surfaces later in code that has no idea serialization happened at all. This guide catalogs what gets lost, what changes type, and what throws, and then shows the built-in tools (toJSON, replacers, revivers and structuredClone) that let you control each case deliberately.

Properties that disappear without a trace

Consider a session object holding a mix of ordinary data, a callback, an explicitly undefined field and a symbol:

const session = {
  userId: 42,
  role: "admin",
  onExpire: () => console.log("session expired"),
  lastActivity: undefined,
  tempToken: Symbol("temp"),
};

console.log(JSON.stringify(session));
// {"userId":42,"role":"admin"}

The output contains only two of the five properties. onExpire, lastActivity and tempToken are gone, with no exception, no warning and nothing in the resulting string to hint that anything was removed. Inside an object, JSON.stringify skips any property whose value is undefined, a function or a Symbol. None of those have a representation in the JSON format, and instead of failing, the serializer returns valid JSON for whatever remains.

The consequence is subtle. A consumer that parses this string and expects lastActivity to be present, even with an undefined value, will not find it. The key is not empty; it never made it into the output in the first place. Code that distinguishes "missing" from "present but undefined", for example with "lastActivity" in obj or Object.keys, will now behave differently on the other side.

Arrays behave differently

The same unsupported values receive different treatment inside an array:

console.log(JSON.stringify([undefined, function () {}, 1]));
// [null,null,1]

Here they become null rather than vanishing. An array cannot drop an element without shifting the index of everything after it, so the serializer keeps the slot and fills it with the nearest thing JSON has to "nothing". The root cause is identical, yet you get two distinct silent behaviors depending on whether the value sat in an object or in an array. Two related edge cases follow the same spirit: NaN and Infinity are serialized as null, and calling JSON.stringify directly on undefined or a function returns undefined instead of a string.

Dates come back as strings

Dates look like they survive a round trip, but only halfway:

const record = { createdAt: new Date() };
const json = JSON.stringify(record);
console.log(json); // {"createdAt":"2026-09-07T14:30:00.000Z"}

const restored = JSON.parse(json);
console.log(restored.createdAt instanceof Date); // false
console.log(typeof restored.createdAt);          // "string"

The date information itself is intact; it is right there in the output as an ISO 8601 string. What is lost is the type. JSON.parse cannot know that a particular string used to be a Date rather than text that merely looks like one, so it returns a string, and it stays a string unless something converts it back.

That matters as soon as code calls a date method after the round trip. Something like record.createdAt.getFullYear() throws, not because the data is wrong, but because its type changed silently along the way. This commonly shows up with API responses, values restored from localStorage, and messages passed through queues.

Circular structures throw instead

Not every failure is quiet. Some objects cannot be serialized at all, and the engine says so loudly:

const parent = { name: "parent" };
const child = { name: "child", parent };
parent.child = child;

JSON.stringify(parent); // TypeError: Converting circular structure to JSON

To build its output, JSON.stringify walks the object graph. When some object in the graph leads back to one of its ancestors, that walk would never end. Rather than recurse without limit, the engine notices the cycle and throws a TypeError immediately. This is one of the few places where the serializer fails honestly, and for a good reason: there is no partial or approximate answer to give, because a cyclic graph genuinely cannot be flattened into a JSON tree. BigInt values are another loud case; they throw a TypeError unless you convert them yourself.

Controlling the output with toJSON

Some built-in types already decide how they should look as JSON, which is why a Date turns into an ISO string rather than an empty object. Any object can opt into the same behavior by defining a toJSON method. When one exists, JSON.stringify calls it and serializes its return value instead of the object's own properties:

class Money {
  constructor(cents) {
    this.cents = cents;
  }
  toJSON() {
    return { amount: this.cents / 100, currency: "USD" };
  }
}

const price = new Money(3499);
console.log(JSON.stringify({ price })); // {"price":{"amount":34.99,"currency":"USD"}}

Without toJSON, the Money instance would be written using its internal shape, {"cents":3499}, leaking an implementation detail that other parts of the system were never supposed to rely on. With it, the object defines its own public representation. Date uses exactly this mechanism: Date.prototype.toJSON is what produces the ISO string.

Keep in mind that this is one-directional. Parsing the output gives you a plain object with amount and currency, not a Money instance; rebuilding the class is the job of the reviver described next.

Replacers and revivers: shaping both directions

JSON.stringify accepts an optional second argument, a replacer function, that is called for every key and value before they are written. Returning undefined from it removes the entry, and returning anything else substitutes that value. That makes it a clean way to filter sensitive fields on the way out:

const user = { id: 1, name: "Priya", passwordHash: "a1b2c3..." };

const safe = JSON.stringify(user, (key, value) => {
  return key === "passwordHash" ? undefined : value;
});
console.log(safe); // {"id":1,"name":"Priya"}

JSON.parse offers the mirror image: a reviver, called for every key and value after parsing. This is the sanctioned fix for the Date problem shown earlier:

const restored = JSON.parse(json, (key, value) => {
  if (key === "createdAt") return new Date(value);
  return value;
});
console.log(restored.createdAt instanceof Date); // true

Two details are worth knowing. Revivers run bottom-up, so nested values are already revived when their parent is processed. And a check on the key name alone matches that key at any depth, so a createdAt field inside some nested object will be converted too; if that is not what you want, check the value's format as well.

Neither of these is an obscure corner of the API. They are the intended way to control serialization precisely, and they are far more reliable than deleting properties before stringifying or manually patching objects after parsing.

Map and Set lose everything

Developers who expect JSON to preserve any kind of object are often caught out here:

const tags = new Set(["urgent", "billing"]);
console.log(JSON.stringify({ tags })); // {"tags":{}}

To the serializer, a Set is neither an array nor a plain object with enumerable own properties, so it becomes an empty object and every value it held is silently discarded. A Map meets the same fate. If either needs to survive, convert it explicitly before stringifying, for example by spreading it into an array:

const json = JSON.stringify({ tags: [...tags] }); // convert Set to array before stringifying

For a Map, [...map] or Object.fromEntries(map) produce serializable forms, and a reviver can rebuild the original collection on the way back in.

Deep copies: use structuredClone

For years, JSON.parse(JSON.stringify(obj)) was a common way to deep-clone an object. It only works by coincidence, and it inherits every limitation described above: functions and undefined values disappear, dates become strings, collections empty out, and circular references throw.

Modern browsers and Node.js provide a purpose-built alternative:

const clone = structuredClone(original);

structuredClone performs a real deep copy using the structured clone algorithm. It preserves Date, Map and Set, and it handles circular references, all of which the JSON trick either mangles or rejects. It has limits of its own, though: it throws a DataCloneError on functions and DOM nodes, and class instances come back as plain objects without their prototype. When the goal is simply to copy data, structuredClone is almost always the better choice. JSON.stringify was never designed for cloning; it was just convenient enough that people used it that way.

Key takeaways

  • JSON.stringify serializes the subset of JavaScript values that JSON can represent, not arbitrary JavaScript values.
  • Inside objects, undefined, functions and symbols are dropped; inside arrays they become null.
  • Dates survive as ISO strings but lose their type; use a reviver to restore them.
  • Circular references and BigInt throw; Map and Set silently serialize to {}.
  • Use toJSON to define an object's public shape, a replacer to filter output, and a reviver to rebuild types.
  • Reach for structuredClone when you want a copy, and treat JSON.stringify as a format-shaped filter whose gaps you handle explicitly, so fields, collections and types do not quietly vanish between parts of your system.