Skip to content
datahacks.dev
JSON

JSON in JavaScript and Node Done Carefully and Correctly

A tested guide to JSON in JavaScript and Node 24: what stringify drops, parse errors, big numbers, dates, circular data, cloning, files and large inputs.

20 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

JSON came out of JavaScript, so people assume that JSON in JavaScript needs no care. It does. JSON.stringify is a lossy conversion that does not tell you what it dropped, JSON.parse is stricter than the object literals you write by hand, numbers stop being exact at a boundary most people never think about, and dates do not survive a round trip as dates. None of this is a bug. The format is smaller than the language, and each place where the two disagree is a place where data changes without an error. This guide goes through those places in order, with output from Node 24 for each one, and links to the focused articles on options, printing, files, dates, circular data, cloning, streaming, modules and revivers.

What stringify keeps and what it drops

JSON.stringify(value) walks the value and writes text. Anything JSON cannot represent is dropped or rewritten, and the rule depends on whether the value is in an object, in an array, or on its own. I ran each case:

Text
{ a: 1, f() {}, u: undefined, s: Symbol("x") }   ->  {"a":1}
[function () {}, undefined, Symbol("x")]          ->  [null,null,null]
JSON.stringify(undefined)                          ->  undefined  (the value, not the text)
{ a: NaN, b: Infinity, c: -0 }                     ->  {"a":null,"b":null,"c":0}
{ d: new Date(Date.UTC(2026, 9, 26, 12, 30)) }     ->  {"d":"2026-10-26T12:30:00.000Z"}
{ d: new Date("nope") }                            ->  {"d":null}
{ m: new Map([[1, 2]]), s: new Set([1]) }          ->  {"m":{},"s":{}}
[1, , 3]                                           ->  [1,null,3]

The pattern is: in an object, a key whose value is a function, undefined or a symbol disappears; in an array the same values become null, because positions cannot be skipped. At the top level, JSON.stringify(undefined) returns the JavaScript value undefined, not a string, which means fs.writeFile with it throws and res.send may send nothing. NaN, Infinity and -Infinity all become null, and -0 becomes 0. An invalid Date becomes null as well, because its toJSON returns null. A Map and a Set serialize as {}: they have no enumerable own properties, so the output is an empty object and your data is gone with no warning.

The list of what is also left out matters when you serialize class instances: symbol keys ({[Symbol("a")]: 1, b: 2} gave {"b":2}), non-enumerable properties, inherited properties (an object whose prototype had inh: 1 serialized only its own key), getters defined on the class (a class with get y() wrote only the instance field x), and private #fields. An Error serializes as {}, because message and stack are not enumerable; you get them back only by listing their names. A Uint8Array becomes an object keyed by index ({"0":1,"1":2}), and a Node Buffer becomes {"type":"Buffer","data":[104,105]}. An array's extra properties are ignored ([1] with a .x property gave [1]).

Key order is the next surprise. Objects order integer-like keys first in ascending order, then string keys in insertion order: { z: 1, 10: 1, 9: 1, a: 1 } came out as {"9":1,"10":1,"z":1,"a":1}. That is JavaScript, not JSON, and it explains why a map keyed by numeric IDs never comes back in the order you inserted. If order matters, use an array of pairs.

The conclusion for design: do not hand an arbitrary object graph to JSON.stringify and expect it to be a faithful copy. Decide the wire shape and build it, either by writing a toJSON() method on the class or by mapping to plain objects first. The three tools for that, toJSON, the replacer function and the replacer array, are the subject of JSON parse and stringify options you probably missed.

toJSON and the replacer

An object with a toJSON method has it called first, with the key as argument, and the result is what gets serialized. That is how Date works (Date.prototype.toJSON returns the ISO string), and it is the hook for your own classes: { x: { toJSON(k) { return "key=" + k; } } } gave {"x":"key=x"}. Note that toJSON runs before the replacer sees the value, so the replacer receives the string, not the Date: inside a replacer the original is available as this[key], and a replacer that wants to treat dates specially has to read it from there. I printed typeof this[k] + "/" + typeof v for a date key and got object/string.

The replacer function is called for every key and value, outermost first, and what it returns replaces the value; returning undefined removes the key. A replacer that drops secret keys at any depth produced {"a":1,"n":{"k":2}} from {a: 1, secret: "x", n: {secret: "y", k: 2}}. A replacer given as an array is an allow-list applied at every level: ["a", "c"] kept a and c and removed d inside c as well. The third argument controls whitespace. A number is a count of spaces and is clamped to ten (a request for 100 produced ten spaces), and a string is the indent itself, clamped to ten characters ("abcdefghijklmnop" produced abcdefghij). Those clamps are specified, and they are why JSON.stringify(x, null, 20) never gives you 20 columns. Wrapper objects unwrap: new String("a"), new Number(1) and new Boolean(false) serialized as "a", 1 and false.

Strings are well formed on the way out. A lone surrogate such as "\ud800" is written as the escape "\ud800" rather than as an invalid UTF-8 sequence, which is the reason JSON.stringify output is always safe to encode as UTF-8. The characters U+2028 and U+2029 are written literally (the string "
" serialized to three characters, the quotes and the character), which has been valid in JavaScript source since ES2019 and is worth remembering if you inline JSON into a script tag.

Pretty printing

JSON.stringify(value, null, 2) is the standard way to get readable output, and it is exact: every nesting level on its own line, every array element on its own line. In the Node REPL or console.log, though, an object is not printed as JSON at all. console.log(obj) uses util.inspect, which prints JavaScript syntax with unquoted keys, truncates nesting beyond depth 2 as [Object], shows only the first 100 array items with ... 20 more items after them, and groups numeric arrays into columns. A nested object five levels deep printed as a: { b: { c: [Object] } }. If you need to see everything, either stringify (console.log(JSON.stringify(x, null, 2))), or ask util.inspect explicitly: console.dir(x, { depth: null }), which printed all levels in JavaScript notation.

The two are different outputs for different jobs. The util.inspect form shows Map and Set contents (Map(1) { 1 => { a: 1 } }) and circular references (<ref *1> { n: 1, self: [Circular *1] }), which JSON cannot, so it is the better debugging view; the stringified form is what a consumer will actually receive, so it is the better check of the wire format. console.table draws an array of flat objects as a table, which is the quickest way to scan records. One practical trap: a string containing a newline is shown with the escape \n inside JSON.stringify output, so the pretty-printed result of {a: "line1\nline2"} keeps the value on one line. Browsers differ in how the console shows objects, with expandable trees in place of text, so the same console.log call looks very different there; pretty printing JSON in the browser console and in Node covers both, including copying clean JSON out of a devtools tree.

Parsing: what is rejected and how the errors read

JSON.parse accepts exactly the JSON grammar and nothing else. The error messages in current V8 (Node 24) are good: they quote the offending token and often the surrounding text, and they give a position. What I fed in and what came out:

Text
[1,]                         Unexpected token ']', "[1,]" is not valid JSON
{'a':1}                      Expected property name or '}' in JSON at position 1 (line 1 column 2)
{a:1}                        Expected property name or '}' in JSON at position 1 (line 1 column 2)
(empty string)               Unexpected end of JSON input
undefined                    "undefined" is not valid JSON
NaN                          "NaN" is not valid JSON
[01]                         Unexpected number in JSON at position 2 (line 1 column 3)
"a<TAB>b"                    Bad control character in string literal in JSON at position 2 (line 1 column 3)
"\x41"                       Bad escaped character in JSON at position 2 (line 1 column 3)
{"a": [1, 2                  Expected ',' or ']' after array element in JSON at position 11 (line 1 column 12)
{"a":1 // c }                Expected ',' or '}' after property value in JSON at position 7 (line 1 column 8)
<html>                       Unexpected token '<', "<html>" is not valid JSON
a BOM before the object      Unexpected token '', "{}" is not valid JSON
[object Object]              "[object Object]" is not valid JSON

Three of these are worth a note. The message "[object Object]" is not valid JSON means you passed an object to JSON.parse, which converted it to a string first: the real bug is that the data was already parsed, often by a framework that did it for you. "undefined" is not valid JSON is the same kind of bug, with a missing value (an absent localStorage key, an empty header). And Unexpected token '<' almost always means an HTML page, an error page or a login redirect, was returned where JSON was expected; look at the body before you look at the parser. The wider set of causes and the fixes for each is in finding the exact line of a JSON syntax error and fixing JSON copied from a web page, and the JavaScript-specific case of a pasted object literal is in fixing JSON that is really a JavaScript object literal. For a truncated body, see repairing truncated JSON cut off mid-stream.

Two inputs the parser accepts that surprise people. JSON.parse(null) returned null, because null is coerced to the string "null", which is valid JSON; a function that parses a possibly-missing value will not fail when the value is null. And duplicate keys are not an error: {"a":1,"a":2} gave { a: 2 }, with the last one winning silently. If a duplicate indicates a bug in the producer, you need a validating parser or a check on the raw text, since the object you get back cannot tell you.

A parse error should be handled where the data enters your program, with the position in the message turned into the context a human needs. Wrapping JSON.parse in a function that catches SyntaxError and rethrows with the source name (the file path, the URL) is the single most useful bit of error handling you can write. Every message above says the position and nothing about where the text came from.

Numbers: the 2^53 boundary

JSON numbers are decimal text with no size limit. JavaScript numbers are 64-bit floating point, so every integer above 2^53 is rounded when parsed. JSON.parse("9007199254740993") returned 9007199254740992, and JSON.parse("12345678901234567890") returned 12345678901234567000. There is no error and no warning. A 64-bit database ID, a Twitter-style snowflake, a blockchain amount or an order number past 2^53 will be silently corrupted by a round trip through JSON.parse, and the corruption is usually discovered when two different IDs compare as equal. Floats are rounded to the nearest representable double, which is the usual behavior (0.1234567890123456789 became 0.12345678901234568), and an exponent that is too large becomes infinity: JSON.parse("1e999") returned Infinity, even though JSON.stringify will never write Infinity back.

The fixes, in order of preference. First, have the producer send large identifiers as strings. A string id is exact in every language, and a JSON schema can say so. Second, if you cannot change the producer, use the reviver's context argument, which Node 24 provides (this is the JSON.parse source text access feature):

JavaScript
const o = JSON.parse(text, (key, value, ctx) =>
  key === "n" ? BigInt(ctx.source) : value);
// o.n === 12345678901234567890n

The third argument holds source, the exact text of the number as it appeared in the input. For a primitive it is the literal text ('1.50', '"x"', 'true', 'null' for the array [1.50, "x", true, null]), which also preserves trailing zeros that the number type would drop, and for arrays and objects the context is an empty object. The feature is recent, so check your runtime: an older Node or browser passes no third argument, and the code above would throw on ctx.source. Third, parse with a library that keeps big numbers as strings or BigInt (json-bigint is the familiar one) when you have to support older runtimes.

On the way out, BigInt is not serializable by default: JSON.stringify({ n: 10n }) threw TypeError: Do not know how to serialize a BigInt. There are three ways to write one. Convert to a string in a replacer, (k, v) => typeof v === "bigint" ? v.toString() : v, which gave {"n":"12345678901234567890"}. Define BigInt.prototype.toJSON to do the same, which works but changes a global that every library in the process shares, so I would avoid it outside a script. Or write a real JSON number with JSON.rawJSON, which exists in Node 24: JSON.rawJSON("12345678901234567890") inside the structure produced {"n":12345678901234567890}, an exact number with no quotes, and JSON.isRawJSON tests for one. The full treatment, with dates, is in dealing with dates and BigInt in JSON stringify.

A related point on precision: -0 is written as 0, and JSON.parse("-0") does return negative zero (Object.is(..., -0) was true), so a round trip changes it. Decimal money in floats is its own topic; keep amounts as integer cents or as strings.

Dates

JSON has no date type. Date.prototype.toJSON writes an ISO 8601 string in UTC, always, whatever the machine's zone: with the process zone set to America/New_York, a local noon on October 26 serialized as "2026-10-26T16:30:00.000Z" for 12:30 local. JSON.parse does not reverse it, so a round trip turns every Date into a string. The correct fix is a reviver, covered below, and the thing to decide beforehand is which fields are dates. A schema or a naming convention is more reliable than a regular expression applied to every string.

Parsing the strings back has a classic trap. A date-only string such as "2026-10-26" is parsed as UTC midnight, while a date-time string without an offset is parsed as local time. In a New York process, new Date("2026-10-26").toISOString() gave 2026-10-26T00:00:00.000Z and new Date("2026-10-26T00:00:00").toISOString() gave 2026-10-26T04:00:00.000Z: the same calendar day, four hours apart. Worse, new Date("2026-10-26").getDate() returned 25 in New York, because UTC midnight is still the evening of the previous day there. A calendar date is not an instant; if you mean "the 26th", keep it as the string "2026-10-26" and never turn it into a Date, or use the Temporal API where your runtime has it. For instants, always include Z or an offset in what you send.

Objects from JSON and the __proto__ key

A JSON document can contain the key "__proto__". JSON.parse treats it as plain data and creates an own property with that name; it does not touch the prototype. I confirmed that Object.keys(JSON.parse('{"__proto__": {"x": 1}}')) was ["__proto__"] and that ({}).x stayed undefined. So the parse itself is safe.

The danger is the next step. Code that copies or merges parsed data can interpret that key as an assignment. Object.assign({}, parsed) used the setter and replaced the new object's prototype: afterwards merged.admin was true and the prototype was no longer Object.prototype. The spread operator, { ...parsed }, kept __proto__ as an ordinary own property, with admin undefined and the prototype intact. This is the mechanism behind prototype-pollution bugs in deep-merge helpers, where the polluted object is the shared prototype and the effect lands on every object in the process. The practical rules: never use a recursive merge on untrusted JSON without skipping __proto__, constructor and prototype keys; validate shapes with a schema (validating JSON with jsonschema in Python shows the idea, and the same exists in JavaScript libraries); and use Object.create(null) or a Map for data keyed by arbitrary user strings.

Revivers

The second argument to JSON.parse is a reviver, called for every value from the innermost out. The order for {"a":{"b":1},"c":[2]} was b, a, 0, c and then the empty key for the root. The return value replaces the value, and returning undefined deletes the key ({"a":1,"b":2} with b revived to undefined gave { a: 1 }). this is the holder object, so a reviver can look at siblings: for key a in {"a":1,"b":2}, Object.keys(this) was ['a', 'b'].

The most common use is turning date strings back into Date. Do it by key where you can (key === "d" ? new Date(value) : value), which produced a real Date with the right timestamp. A pattern-based reviver is convenient and has a failure mode I reproduced: a regular expression that matches ISO timestamps anchored with ^ and $ converted the field id, which held a timestamp-shaped string, and left the note field alone only because the string had extra words around the timestamp. A text field that happens to hold exactly a timestamp is converted whether you wanted it or not. The pattern is a guess; the key list is a decision. Using a reviver function to revive dates in JSON.parse goes through both.

Circular references

JSON.stringify throws on a cycle, and the message in Node 24 is unusually helpful. For an object with child.list holding the root, it printed:

Text
TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    |     property 'child' -> object with constructor 'Object'
    |     property 'list' -> object with constructor 'Array'
    --- index 0 closes the circle

That path tells you where to cut the cycle. Cycles are common in object graphs with parent links, ORM relations, DOM nodes, Error causes and request/response objects. The usual workaround is a replacer that remembers what it has seen. The popular version uses a WeakSet and is wrong in one case. Given { a: shared, b: shared }, where shared is the same object referenced twice, there is no cycle, and plain JSON.stringify correctly wrote {"a":{"x":1},"b":{"x":1}}. The WeakSet replacer wrote {"a":{"x":1},"b":"[Circular]"}, corrupting valid data, because it cannot tell "seen before" from "currently on the path". A replacer that keeps a stack of ancestors and checks only against it got that case right and still marked the true cycle ("self":"[Circular]"). The longer comparison, with the code, is in stopping JSON.stringify from throwing on circular references. For debugging output, util.inspect handles cycles natively and marks them as <ref *1> and [Circular *1]. structuredClone also copies cycles correctly (the clone's self pointed at the clone), which makes it the right tool for copying such a graph, though not for writing it as JSON.

Deep cloning: structuredClone against a JSON round trip

JSON.parse(JSON.stringify(x)) was the folk answer for a deep copy for a decade. It is a lossy copy, for every reason in the first section. With the test object holding a Date, Map, Set, undefined field, NaN and a regular expression, structuredClone returned a Date, a Map, a Set, kept undefined as a present key, kept NaN, a BigInt and a RegExp; the JSON route returned a string for the date, {} for the map and the set, dropped u and turned NaN into null.

structuredClone is not a universal answer either. It throws DataCloneError for functions (f() {} could not be cloned.), loses prototypes (a class instance came back with constructor Object, and its getter was gone), does not copy symbol keys (the count was 0), and reads getters once and stores the value. It does clone Error objects as the right type and handles cycles. On speed, the guess that the native function must win was wrong here: for 50,000 records of nested data, structuredClone took 74 ms and the JSON round trip took 55 ms. Different shapes and engines shift this, so measure before choosing on performance; choose on semantics. Both are deep copies (the spread operator is shallow, and a nested array pushed through a spread copy appeared in the original). The comparison table and the decision rule are in deep cloning objects with structuredClone versus JSON.

Files and modules in Node

Reading a JSON file is two steps, and each can fail separately. fs.readFile(path) without an encoding returns a Buffer; JSON.parse accepted it because it stringifies its argument (JSON.parse(buffer) worked on the small orders file), but passing "utf8" is clearer. File errors carry a code you can branch on: ENOENT for a missing file and EISDIR for a directory, both before any parsing. A truncated file fails in the parser with a position, as in Expected ':' after property name in JSON at position 24. And a file written by a Windows tool may begin with a byte order mark that JSON.parse refuses, so strip it with .replace(/^/, "") as I did to make a BOM file parse (why JSON parse fails on a byte order mark).

Writing needs a string. fs.writeFile(path, JSON.stringify(undefined)) threw ERR_INVALID_ARG_TYPE because the value was undefined, and passing the object itself threw the same error. The pattern I use for anything that matters is to write to a temporary file in the same directory and rename it over the target, which on a local filesystem replaces the old content in one step so that a crash never leaves half a file: write JSON.stringify(data, null, 2) + "\n" to out.json.tmp, then rename it to out.json. The details, including fs.promises and error codes, are in reading and writing a JSON file in Node.

Loading a JSON file as a module has changed. require("./cfg.json") has always worked in CommonJS and parses at load, with the error carrying the file path (/tmp/badcfg.json: Expected double-quoted property name in JSON at position 7). In ES modules, a bare import cfg from "./cfg.json" fails in Node with ERR_IMPORT_ATTRIBUTE_MISSING: Module ... needs an import attribute of "type: json", and the working form is:

JavaScript
import cfg from "./cfg.json" with { type: "json" };

The result is the default export only; there are no named exports (Object.keys of the module was ['default']), and the module is cached and shared, so mutating it changes it for every importer. Bundlers handle JSON imports their own way, usually without the attribute; the differences are in importing a JSON file as a module in Node and bundlers.

Large inputs

JSON.parse needs the whole text in memory as one string, and V8's maximum string length is 536,870,888 characters (about 512 MB): "x".repeat(2 ** 29) threw RangeError: Invalid string length, and so did JSON.stringify of an array of 2^25 long strings. Below that limit, the practical limit is memory and time. The 36.4 MB array of 200,000 events read and parsed in 401 ms, and the process resident memory grew from 52 to 147 MB. That is far smaller than the equivalent in Python, because V8 stores the parsed objects compactly, and it is the reason many Node programs never need to stream.

When the file is newline-delimited JSON, you do not need a streaming parser at all: read lines and parse each one. A readline loop over the 200,000-line file summed the amounts in 316 ms with a one-line object in memory at a time, and printed [200000, 2494844.56], the same total as every other tool in this series. For one big array, a true streaming parser (stream-json with StreamArray) kept memory flat and produced the same total, in 5,139 ms, about sixteen times slower than the line reader and thirteen times slower than parsing the whole file. Use it when the file does not fit, and use lines when you control the format. Streaming large JSON in Node has the code and the numbers, and the same approach in Python is in handling large JSON files in Python with ijson. For the analysis side, DuckDB reads the same files with SQL and no loop (querying JSON files with DuckDB).

A checklist for a JSON boundary

When JSON crosses into or out of your code, run through this list:

  1. Do you know the shape? Validate on the way in; do not trust JSON.parse to give you what you hoped for.

  2. Are any numbers larger than 2^53? Strings, or context.source, or BigInt.

  3. Are there dates? UTC Z strings out, a reviver by key in.

  4. Does anything non-plain go out? Map, Set, class instances, Error, typed arrays: map to plain objects first.

  5. Could there be a cycle? An ancestor-stack replacer, or restructure.

  6. Is the key order meaningful? It is not, for integer-like keys.

  7. Is the data user-controlled? Watch __proto__ in merges.

  8. Is the size bounded? If not, lines or a stream.

  9. Is the error message useful? Add the source name.

A quick look at any document is available in the browser with the JSON formatter, and two versions can be compared in the JSON diff.

When JSON is the wrong format for the data in JavaScript

You need types JSON lacks. Dates, Map, Set, BigInt, binary data and undefined have no JSON form. structuredClone, the v8.serialize function in Node, or a typed binary format keep them, at the cost of leaving the text world.

You need comments or trailing commas for humans. Configuration edited by people is better in a format that allows comments; JSON with a parse step that strips them is a different format with the same extension.

The data is large and flat. A million rows of uniform records are smaller and faster as CSV, Parquet or a database table than as an array of objects.

You need exact decimals. Money should travel as strings or integer cents, regardless of the format.

Cyclic or shared references matter. If the graph's identity matters, JSON flattens it; use ids and a lookup table.

The behavior above follows the language specification's JSON object definition and the MDN reference for JSON, and the grammar itself is in RFC 8259.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.