JSON.stringify turns Maps and Sets into {}
published
TL;DR
JSON.stringify(new Map([['a', 1]])) returns "{}". So does JSON.stringify(new Map()). There is no error and no warning — an empty Map and a full one serialise identically, so the bug looks like “the data was never there” rather than “the data was dropped on write”. Fix it with a replacer that converts Maps and Sets on the way out, or give the class a toJSON(). If the destination is another JS runtime rather than a wire format, use structuredClone instead — it keeps both types intact.
The problem
You refactor a plain object into a Map, because you want ordered keys and non-string keys and every blog post for a decade has told you to. Then you cache the result:
const sessions = new Map([
['u_1', { seen: 3 }],
['u_2', { seen: 9 }],
]);
await fs.writeFile('cache.json', JSON.stringify({ sessions }));
{"sessions":{}}
Nothing threw. The file was written. The process exited 0. Tomorrow the cache reads back as an empty object and the only symptom is that every session looks new.
Set does the same thing:
JSON.stringify({ tags: new Set(['a', 'b']) });
// '{"tags":{}}'
The reason this one bites so hard is that the failure is indistinguishable from success. Compare the two calls:
JSON.stringify(new Map()); // '{}'
JSON.stringify(new Map([['a', 1]])); // '{}'
There is no output difference to notice in a log, no shape difference to catch in a schema check that only asserts “is an object”, and no exception to trip a test.
Why it happens
JSON.stringify has no special case for Map or Set. Its rule for a plain object is: walk the object’s own enumerable string-keyed properties. A Map does not keep its entries in properties — they live in internal slots reachable only through Map.prototype.get/set, which the serialiser never calls. So the walk finds zero own enumerable properties and writes {}, entirely correctly by its own rules.
That same rule is what quietly drops a longer list of things. Verified on Node v26.2.0:
| Value | JSON.stringify result | What happened |
|---|---|---|
new Map([['a',1]]) | {} | entries live in internal slots |
new Set([1,2]) | {} | same |
{ a: 1, b: undefined } | {"a":1} | key removed entirely |
[1, undefined, 3] | [1,null,3] | can’t drop an index, so it becomes null |
{ a: 1, f() {} } | {"a":1} | functions are skipped |
{ a: Infinity, b: NaN } | {"a":null,"b":null} | no JSON literal for either |
{ [Symbol('k')]: 1, v: Symbol('x') } | {} | symbol keys and values both vanish |
new Uint8Array([1,2,3]) | {"0":1,"1":2,"2":3} | serialises as index properties, not an array |
{ a: 1n } | throws | TypeError: Do not know how to serialize a BigInt |
| circular reference | throws | TypeError |
Note the asymmetry that makes this genuinely dangerous: BigInt and circular references throw, so you find them in the first test run. Maps, Sets, undefined and functions do not. The loud failures are the safe ones.
What to do
Option 1 — a replacer, when the fix belongs at the call site
The replacer runs before serialisation and sees the live value, so it can hand back something JSON understands:
const jsonSafe = (_key, value) => {
if (value instanceof Map) return Object.fromEntries(value);
if (value instanceof Set) return [...value];
return value;
};
JSON.stringify({ sessions, tags: new Set(['a']) }, jsonSafe);
// '{"sessions":{"u_1":{"seen":3},"u_2":{"seen":9}},"tags":["a"]}'
⚠️ Object.fromEntries only round-trips a Map whose keys are strings. Object and number keys get coerced by the object literal itself, and two different keys can collide:
const m = new Map([[{ id: 1 }, 'a'], [2, 'b']]);
JSON.stringify(Object.fromEntries(m));
// '{"2":"b","[object Object]":"a"}' ← key destroyed
For a Map with non-string keys, serialise the entries array instead — it survives intact and rebuilds exactly:
if (value instanceof Map) return [...value]; // in the replacer
const revived = new Map(JSON.parse(text)); // on the way back
Option 2 — toJSON(), when the fix belongs on the type
JSON.stringify calls toJSON() on any value that has one, so a subclass fixes every call site at once:
class Tags extends Set {
toJSON() {
return [...this];
}
}
JSON.stringify({ t: new Tags([1, 2]) });
// '{"t":[1,2]}'
This is the right shape when the type is yours and the JSON form is a property of the type rather than of one particular write.
Option 3 — don’t use JSON at all
If you are copying between two JavaScript contexts — a worker, an iframe, an IndexedDB write, a Node postMessage — structuredClone preserves both types with no adapter:
const copy = structuredClone({ m: new Map([['a', 1]]), s: new Set([1]) });
copy.m instanceof Map; // true
copy.s instanceof Set; // true
It has its own exclusions — functions throw, class prototypes are stripped — covered in structuredClone rejects functions and strips prototypes.
Caveats
JSON.parsecannot undo this on its own. Whatever shape you chose on the way out, you need a matchingreviveror an explicitnew Map(...)on the way in. Nothing restores a Map automatically.- A
replacerruns on every value in the tree, including deeply nested ones. That is what you want here, but it also means an expensive replacer costs you on large payloads. toJSONis inherited. Adding it to a base class changes serialisation for every subclass, which is convenient right up until it is surprising.- Type checkers will not save you.
JSON.stringifyis typed as acceptingany, so aMapin the payload is a clean compile in TypeScript. - The
Uint8Arrayrow is not a workaround. It looks like it survives, but it comes back as a plain object with numeric keys, not a typed array — you still need a deliberate conversion.