JSON in JavaScript, 2024–2026: What Actually Changed in JSON.parse
Every claim on this page was run on this machine: Node v24.21.0, V8 13.6.233.17-node.53. Outputs are pasted, not remembered. Where a proposal has not shipped, that is stated rather than hinted at.
The reviver is not what people think it is
JSON.parse(text, reviver) calls your function bottom-up, leaves first, with (key, value) — and the value has already been coerced by the parser. Watch the order on a nested document:
const order = [];
JSON.parse('{"a":{"b":[1,2]},"c":3}', (k, v) => { order.push(`${k}:${JSON.stringify(v)}`); return v; });
// 0:1 | 1:2 | b:[1,2] | a:{"b":[1,2]} | c:3 | :{"a":{"b":[1,2]},"c":3}
Array indexes arrive as string keys "0", "1". The final call gets key "" and the completed root object. Returning undefined deletes the key — that is the documented removal mechanism, not a bug:
JSON.parse('{"a":1,"b":2}', (k, v) => (k === "a" ? undefined : v)); // {"b":2}
Two pitfalls follow directly from bottom-up evaluation with already-lossy values.
Dates. JSON has no date type, so Date becomes an ISO string via the toJSON hook and never comes back as a Date:
JSON.stringify(new Date("2026-10-01T15:30:00Z")); // "2026-10-01T15:30:00.000Z"
JSON.parse('{"d":"2026-10-01T15:30:00Z"}').d instanceof Date; // false
A reviver that matches an ISO pattern and constructs Date objects fixes the common case:
const ISO = /^\d{4}-\d{2}-\d{2}T/;
JSON.parse(raw, (k, v) => (typeof v === "string" && ISO.test(v) ? new Date(v) : v));
Honest caveat: that is a heuristic. A field named notes containing an ISO-looking string becomes a Date, and the reviver cannot tell you which fields were strings in the source — no type tag, no context. If dates matter, put an explicit "__type":"date" envelope on them.
Big integers. By the time your reviver sees a number, precision is already gone. JSON.parse throws away digits before you get a vote:
JSON.parse('{"n":9007199254740993}').n; // 9007199254740992
A reviver checking typeof v === "string" for big numbers does nothing, because the parser hands you a number, not the raw text:
// Broken pattern that circulates widely:
JSON.parse('{"n":12345678901234567890123}', (k, v) =>
typeof v === "string" ? BigInt(v) : v);
// → { n: 1.2345678901234568e+22 } still a number, still wrong
What actually shipped: source text access
The TC39 proposal proposal-json-parse-with-source reached Stage 4 and shipped behind the shipped name: the reviver now takes a third argument carrying the raw source text for primitive values. On Node 24 it works:
const BIG = /^-?\d{16,}$/;
const revive = (k, v, ctx) =>
typeof v === "number" && ctx?.source && BIG.test(ctx.source) ? BigInt(ctx.source) : v;
const o = JSON.parse('{"id":12345678901234567890123,"small":42}', revive);
typeof o.id; // "bigint"
String(o.id); // "12345678901234567890123"
typeof o.small; // "number"
The source is exact — escapes and exponent notation preserved (1e3 arrives as "1e3", "a\u0062" as the literal backslash text). It is supplied for primitives only; objects and arrays get undefined, and the root call gets undefined. That is the whole feature, and it is the fix for the big-integer problem above.
Companion piece: JSON.rawJSON(text) and JSON.isRawJSON(value) are present on this build. One sharp edge — JSON.rawJSON only accepts primitive JSON text. Objects throw:
JSON.stringify(JSON.rawJSON('12345678901234567890123')); // '12345678901234567890123' ✔
JSON.stringify(JSON.rawJSON('{"big": 12345678901234567890123}')); // SyntaxError ✗
So rawJSON is for splicing a vetted scalar into a larger document without re-serialising it; it is not a lossless-object wrapper. Frozen objects, isRawJSON returns true, and a plain object returns false.
Honest caveat on availability: this is a V8/Node 24 story. Browser support is uneven, so feature-detect before relying on it and keep the string-quoting fallback below for code that must run everywhere.
The supersets never came, and one still won't arrive
JSON.parse still rejects everything the JS-family dialects allow. Verified rejects on Node 24:
{"a":1,} trailing comma REJECT
{a:1} naked key REJECT
//x\n{"a":1} leading comment REJECT
{"a":1} //x trailing comment REJECT
`t` template literal REJECT
{"a":.5} bare-leading-dot REJECT
{"a":+1} explicit plus REJECT
There is no JSON.supersetParse on JSON's own properties. The JSON superset proposal (TC39 stage 1, proposal-json-superset) has not progressed to a shipped feature as of this writing — it remains an idea with a README. JSON5 and JSONC stay where they belong: editor and config files your toolchain parses deliberately, never an API contract. When a human pastes a commented config at you, run it through Auto-Repair and read what it changed.
1e999 → Infinity, but stringify(Infinity) → null
The asymmetry catches everyone. JSON grammar permits an unbounded number literal, so the parser evaluates 1e999 and IEEE-754 saturates it:
JSON.parse('1e999'); // Infinity
JSON.parse('[1e999,-1e999]'); // [Infinity, -Infinity]
JSON.stringify(Infinity); // null
JSON.stringify({ a: Infinity }); // {"a":null}
JSON.stringify([1, Infinity, 3]); // [1,null,3]
Not a bug — JSON has no Infinity literal, and undefined/functions/Infinity/NaN all serialise to null inside objects and arrays. The damage: the round trip is lossy and silent. If a field can overflow, reject it at the boundary:
const strict = (v) => { if (typeof v === "number" && !Number.isFinite(v)) throw new TypeError("non-finite number"); return v; };
JSON.parse(raw, (k, v) => strict(v));
BigInt without tears
JSON.stringify(1n) throws TypeError: Do not know how to serialize a BigInt. Three patterns that work, in order of preference:
- Strings on the wire, always.
"id": "12345678901234567890123". Every consumer, every language, no precision question. This is the right answer and the other two are compromises. - Reviver round trip with source access (Node 24, shown above) plus a replacer that tags the type:
String(v) + "n"on the way out, regex-strip on the way in. - Quote-by-regex before parsing for runtimes without source access — crude, and it must run before
JSON.parse, never after:
JSON.parse(text.replace(/:\s*(\d{16,})(?=[,\s}\]])/g, ': "$1"')); // id arrives as string
structuredClone vs the JSON round trip
structuredClone is available in Node ≥17 and every current browser, and it is strictly better for state snapshots:
const s = { d: new Date("2026-10-01T00:00:00Z"), m: new Map([["k",1]]), u: undefined };
const rt = JSON.parse(JSON.stringify(s));
const cl = structuredClone(s);
rt.d instanceof Date; // false (string)
cl.d instanceof Date; // true
"u" in rt; // false (undefined dropped)
"u" in cl; // true
cl.m.get("k"); // 1 (Map survives)
JSON round-trip loses Date, Map, Set, RegExp, typed arrays' prototypes, -0, undefined keys, and turns sparse-ish holes into null. structuredClone preserves Date, Map, Set, ArrayBuffer, Blob, File, Error, and cyclic references; it throws DataCloneError on functions, Symbols, and DOM nodes. Use structuredClone for worker handoff and in-memory state; use JSON only when bytes must cross a boundary, because that is the only reason it exists.
FAQ
Should I upgrade to Node 24 purely for reviver source access? No. It is a nice fix for big-integers-on-the-wire, but the correct architecture is still strings for IDs. Feature-detect it; do not assume it.
Why does my toJSON method get called? JSON.stringify checks for toJSON on every value before serialising — the same hook Date uses. Any object can define it; your ISO strings may be coming from something other than a Date.
Why does __proto__ in a payload not poison my object? JSON.parse creates it as an ordinary own property, so JSON.parse('{"__proto__":{"evil":true}}').evil is undefined. That is parse-time only — a merge helper or Object.assign onto an existing target can still walk it. See patterns and gotchas.
What changed for this website? Nothing user-visible: this tool is JSON.parse plus JSON.stringify in your browser, so you get whatever the engine provides. Feature-detect before depending on it. The format buttons remain 2-space and 4-space only.