Mastering JSON in JavaScript: An Advanced Guide
JSON.parse and JSON.stringify look like two trivial functions, but they hide revivers, replacers, silent data loss, and a handful of exceptions that surface only in production. This guide is example-first and gotcha-driven: every section shows real output and the trap that catches people. It is JavaScript-primary, with one section contrasting Python and Go for dates and big integers.
Reviving Values with the JSON.parse Reviver
JSON.parse(text, reviver) takes an optional second argument: a function called for every key/value pair as the result is built. Its most common job is turning ISO date strings back into real Date objects, because JSON has no date type.
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?$/;
function dateReviver(key, value) {
if (typeof value === "string" && ISO.test(value)) {
return new Date(value);
}
return value;
}
const json = '{"id":7,"createdAt":"2026-06-25T10:30:00Z","tags":["a","b"]}';
const obj = JSON.parse(json, dateReviver);
obj.createdAt instanceof Date; // true
obj.createdAt.getUTCFullYear(); // 2026
The critical detail is order: the reviver runs bottom-up. Leaf values are transformed first, then their containing objects, and the root last. By the time your reviver sees a parent object, its children have already been replaced. The root itself is passed with an empty-string key "" as the final call:
JSON.parse('{"a":{"b":1}}', (key, value) => {
console.log(JSON.stringify(key), value);
return value;
});
// "b" 1 <- leaf first
// "a" { b: 1 } <- then the object containing it
// "" { a: ... } <- root last
Gotcha: if you return undefined from the reviver, that property is deleted from the result rather than set to undefined. This is a handy way to strip fields, but it bites people who return undefined by accident (for example, forgetting the final return value). Also note this inside the reviver is bound to the object currently holding the key, which lets you inspect siblings if needed.
The JSON.stringify Replacer and toJSON()
JSON.stringify(value, replacer, space) has two forms of replacer. The array form is an allowlist of property names to keep:
const user = { id: 1, name: "Ada", password: "secret", ssn: "000-00-0000" };
JSON.stringify(user, ["id", "name"]);
// '{"id":1,"name":"Ada"}'
The function form is called per key/value (top-down, unlike the reviver) and lets you transform values:
JSON.stringify({ amount: 19.5, secret: "x" }, (key, value) =>
key === "secret" ? undefined : value
);
// '{"amount":19.5}'
Independently, any object can expose a toJSON() method. JSON.stringify calls it and serializes the return value instead of the object. This is exactly how Date becomes a string:
new Date("2026-06-25T00:00:00Z").toJSON(); // "2026-06-25T00:00:00.000Z"
class Money {
constructor(cents) { this.cents = cents; }
toJSON() { return (this.cents / 100).toFixed(2); }
}
JSON.stringify({ price: new Money(1995) }); // '{"price":"19.95"}'
What stringify Silently Drops
This is the section that prevents the most bugs. JSON.stringify does not throw on unsupported values inside structures — it quietly omits or rewrites them, and the rules differ between objects and arrays:
JSON.stringify({
a: undefined, // dropped
b: () => 1, // function dropped
c: Symbol("s"), // symbol dropped
d: NaN, // -> null
e: Infinity, // -> null
f: 1,
});
// '{"d":null,"e":null,"f":1}'
JSON.stringify([undefined, () => 1, Symbol("s"), NaN, 3]);
// '[null,null,null,null,3]'
In objects, undefined, functions, and symbols vanish. In arrays, they become null so positions are preserved. NaN and Infinity always become null. Gotcha: a round trip through JSON is therefore lossy — JSON.parse(JSON.stringify(x)) is not an identity function for anything containing dates, undefined, functions, or non-finite numbers.
BigInt Does Not Serialize
JSON.stringify({ id: 10n });
// Uncaught TypeError: Do not know how to serialize a BigInt
BigInt is the one common type that throws rather than being silently dropped. The fix is a replacer/reviver pair that round-trips the value as a tagged object:
function bigIntReplacer(key, value) {
return typeof value === "bigint" ? { $bigint: value.toString() } : value;
}
function bigIntReviver(key, value) {
if (value && typeof value === "object" && "$bigint" in value) {
return BigInt(value.$bigint);
}
return value;
}
const text = JSON.stringify({ id: 9007199254740993n }, bigIntReplacer);
// '{"id":{"$bigint":"9007199254740993"}}'
JSON.parse(text, bigIntReviver).id; // 9007199254740993n
Why bother? Because any integer above Number.MAX_SAFE_INTEGER (2^53 − 1) loses precision when stored as a JS number — the value 9007199254740993 silently becomes 9007199254740992. Database IDs, X/Twitter snowflake IDs, and 64-bit counters routinely exceed that boundary. See JSON Numbers and Precision for the full breakdown of why these IDs break and how to keep them as strings end to end.
Circular References
const a = {};
a.self = a;
JSON.stringify(a);
// Uncaught TypeError: Converting circular structure to JSON
A reference cycle makes the serializer throw. The standard fix is a replacer backed by a WeakSet that drops any object it has already seen:
function getCircularReplacer() {
const seen = new WeakSet();
return (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[Circular]";
seen.add(value);
}
return value;
};
}
JSON.stringify(a, getCircularReplacer());
// '{"self":"[Circular]"}'
A WeakSet is the right tool here: it holds objects without preventing garbage collection, and it gives O(1) membership checks. Gotcha: create the WeakSet inside a factory and return a fresh replacer each call. If you share one WeakSet across multiple stringify calls, the same object legitimately appearing in two separate serializations would wrongly be reported as circular on the second pass.
Deep Clone: structuredClone() vs the JSON Trick
For years the idiom for a deep copy was JSON.parse(JSON.stringify(x)). Modern runtimes (Node 17+, all current browsers) provide structuredClone(), which is both safer and more capable.
const original = {
when: new Date("2026-06-25T00:00:00Z"),
tags: new Set(["a", "b"]),
lookup: new Map([["k", 1]]),
bytes: new Uint8Array([1, 2, 3]),
note: undefined,
};
original.loop = original; // cycle
// JSON trick:
JSON.parse(JSON.stringify(original));
// Throws: Converting circular structure to JSON
// ...and even without the cycle it would mangle the rest (below)
Compare the two approaches on a non-circular object:
| Feature | JSON.parse(JSON.stringify(x)) |
structuredClone(x) |
|---|---|---|
Date |
becomes a string |
stays a real Date |
Map / Set |
becomes {} |
preserved |
| typed arrays | becomes {"0":1,...} |
preserved |
undefined props |
dropped | preserved |
| functions | dropped | throws DataCloneError |
| circular refs | throws | handled correctly |
BigInt |
throws | preserved |
const safeCopy = structuredClone(original);
safeCopy.when instanceof Date; // true
safeCopy.lookup instanceof Map; // true
safeCopy.loop === safeCopy; // true (cycle preserved)
Gotcha: structuredClone cannot clone functions or DOM nodes, and it does not preserve class identity — it throws DataCloneError on functions and returns a plain object (losing the prototype) for class instances. Use the JSON trick only when you specifically want a JSON-shaped, function-free, date-as-string snapshot.
Performance and Safety
JSON.parse and JSON.stringify are synchronous and run on the main thread. A multi-megabyte string can block rendering and input for hundreds of milliseconds, freezing the UI. For large payloads, move parsing into a Web Worker (or a Node worker thread) so the main thread stays responsive, or use a streaming parser that yields incrementally instead of buffering the whole document. See Parsing Large JSON Files for streaming and chunking strategies.
// worker.js
self.onmessage = (e) => {
const data = JSON.parse(e.data); // heavy work off the main thread
self.postMessage(data);
};
Gotcha — never use eval to parse JSON. It is slower, and any string you parse becomes executable code, which is a remote-code-execution hole the moment the input is untrusted. JSON.parse only ever produces data, never runs it. There is no scenario where eval('(' + text + ')') is the right call.
A Reusable safeParse Helper
JSON.parse throws on malformed input, so unguarded calls are a frequent crash source. A discriminated result type lets callers branch without try/catch at every site:
function safeParse(text, reviver) {
try {
return { ok: true, value: JSON.parse(text, reviver) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
const result = safeParse(req.body);
if (result.ok) {
use(result.value);
} else {
console.warn("Bad JSON:", result.error); // e.g. "Unexpected token } in JSON at position 14"
}
The shape { ok: true, value } | { ok: false, error } is a discriminated union: checking result.ok narrows the type so TypeScript knows value exists only on success. For the catalog of messages you might see in error, see JSON Parse Errors.
Cross-Language Contrast: Dates and Big Integers
JavaScript's date-as-string and 2^53 number ceiling are language quirks, not JSON quirks. Other languages hook in differently.
Python parses integers with arbitrary precision natively, so 9007199254740993 survives untouched. Dates still need a hook — object_hook runs per decoded object, the rough equivalent of a reviver:
import json
from datetime import datetime
def date_hook(obj):
for k, v in obj.items():
if isinstance(v, str) and v.endswith("Z"):
try:
obj[k] = datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
pass
return obj
data = json.loads('{"id": 9007199254740993, "at": "2026-06-25T10:30:00Z"}', object_hook=date_hook)
print(type(data["id"]), data["id"]) # <class 'int'> 9007199254740993
print(type(data["at"])) # <class 'datetime.datetime'>
Go decodes JSON numbers into float64 by default — which means it also loses precision above 2^53 unless you opt into json.Decoder.UseNumber(), which keeps them as exact json.Number strings. Dates are handled by implementing UnmarshalJSON on a custom type:
dec := json.NewDecoder(strings.NewReader(`{"id": 9007199254740993}`))
dec.UseNumber()
var m map[string]json.Number
dec.Decode(&m)
id, _ := m["id"].Int64() // 9007199254740993, exact
type Event struct {
At time.Time `json:"at"` // time.Time implements UnmarshalJSON for RFC 3339
}
Same JSON document in all three languages:
{ "id": 9007199254740993, "at": "2026-06-25T10:30:00Z" }
The takeaway: JSON itself stores numbers and date strings faithfully. Precision and type loss are introduced by the parser — JavaScript and Go default to lossy floats, Python defaults to exact integers, and dates always require explicit revival.
Related Tools & Resources
Tools
- JSON Validator — Catch the malformed input that makes
JSON.parsethrow before it reaches your code. - JSON Stringify — Preview exactly what
stringifykeeps, drops, and rewrites without writing a script. - JSON Formatter — Pretty-print serialized output to inspect dropped fields and nesting at a glance.
- JSON to TypeScript — Generate types so your parsed
safeParseresults are statically checked.
Learn More
- Working with JSON in Python — Go deeper on
object_hook,default, and arbitrary-precision integers. - JSON Numbers and Precision — Why IDs above 2^53 break and how to keep them as strings.
- JSON Parse Errors — Decode the exact messages your
safeParseerror branch will surface. - Parsing Large JSON Files — Streaming and worker strategies for payloads that block the main thread.
Related Articles
Formatting JSON: When It Helps, When It Hurts
A practical guide to JSON beautifiers: pretty-print for humans, minify for transport, sort keys for diffs, and the lossy gotchas of round-tripping through stringify.
Common JSON Mistakes and How to Avoid Them
The 20 most common JSON syntax errors developers make, organized by category with examples and fixes.
How to Fix 'Unexpected End of JSON Input' Error
Learn what causes the 'Unexpected end of JSON input' error and how to fix it with practical solutions for JavaScript, Python, and other languages.
How to Open JSON Files: A Practical Guide
Open, view, and troubleshoot JSON files on macOS, Windows, and Linux with real commands for jq, Python, VS Code, and browsers — plus fixes for common errors.