JSON in JavaScript: Parse, Stringify & Common Pitfalls (2026)
JavaScript's entire JSON API consists of two methods on the global JSON object: JSON.parse and JSON.stringify. Everything else — pretty-printing, filtering, handling BigInt, recovering from errors — is a variation on those two. Understanding them well repays itself many times over because you will use them every day of your career.
The two functions
JSON.parse(text) turns a JSON string into a JavaScript value. JSON.stringify(value) does the reverse.
const json = '{"name":"Ada","skills":["math","computing"]}'
const obj = JSON.parse(json)
console.log(obj.name) // "Ada"
console.log(obj.skills.length) // 2
const back = JSON.stringify(obj)
console.log(back === json) // true
The data is the same; the difference is its representation. obj is an in-memory JavaScript value with methods, prototype chains and mutable fields. json is a string of characters that can be sent over a network, written to a file, or stored in localStorage.
Pretty-printing
JSON.stringify takes three arguments. The third controls indentation:
JSON.stringify(obj) // single line, no whitespace
JSON.stringify(obj, null, 2) // two-space indented
JSON.stringify(obj, null, 4) // four-space indented
JSON.stringify(obj, null, '\t') // tab indented
For human-readable output (logs, debugging, files), pass 2. For wire payloads, omit the third argument entirely — every byte counts.
The replacer (second argument of stringify)
The second argument is a function or array that filters or transforms values on the way out.
As a function, it receives (key, value) for every key/value pair in the document — including the root, where key is the empty string. Returning undefined omits the key entirely:
// Drop the password before logging
const safe = JSON.stringify(user, (key, value) =>
key === 'password' ? undefined : value
)
As an array, it acts as an allow-list of keys:
JSON.stringify(user, ['name', 'email'])
// Only these two keys survive
Combined with the third argument:
JSON.stringify(user, ['name', 'email'], 2)
Both filter and indent.
The reviver (second argument of parse)
JSON.parse accepts a reviver function that transforms values on the way in. The most common use is turning ISO date strings back into Date objects:
const obj = JSON.parse(text, (key, value) => {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value)
}
return value
})
Now obj.createdAt is a Date, not a string. The reviver also receives the root value last (with key === ''), so you can transform the entire result.
Things that don't survive a JSON round-trip
JSON's value types are a subset of JavaScript's. Some things get mangled or dropped silently:
undefined— omitted from objects, becomesnullin arraysDate— serialised to an ISO string;parsewon't restore it without a reviverMap,Set— become{}- Functions and class instances — omitted (lose all methods)
BigInt— throwsTypeError: Do not know how to serialize a BigInt- Circular references — throws
TypeError: cyclic object value Symbol-keyed properties — omitted- Non-enumerable properties — omitted
This is why JSON.parse(JSON.stringify(obj)) is a flawed deep-clone idiom. It works for "pure data" objects but breaks for anything with methods, Dates, Maps, or special types. Use structuredClone(obj) instead — it's faster, handles more types, and is the right tool for cloning.
Handling BigInt
BigInt was added to JavaScript to represent integers larger than 2^53 - 1. JSON has no equivalent, so JSON.stringify refuses:
JSON.stringify({ id: 9007199254740993n })
// TypeError: Do not know how to serialize a BigInt
The workaround is a replacer that converts BigInt to a string:
const json = JSON.stringify(
obj,
(key, value) => typeof value === 'bigint' ? value.toString() : value
)
On the parse side, you need to know which fields are BigInt because the JSON has no type tag — "9007199254740993" looks the same as any other string. A reviver based on schema knowledge:
const bigIntFields = new Set(['id', 'parentId', 'userId'])
const obj = JSON.parse(json, (key, value) =>
bigIntFields.has(key) ? BigInt(value) : value
)
Detecting circular references
A real-world cause of circular references: a parent object holds a reference to a child, the child holds a back-reference to the parent. Trying to stringify either crashes.
const parent = { name: 'root' }
const child = { name: 'leaf', parent }
parent.child = child
JSON.stringify(parent)
// TypeError: Converting circular structure to JSON
A defensive serialiser:
function safeStringify(obj, indent = 0) {
const seen = new WeakSet()
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]'
seen.add(value)
}
return value
}, indent)
}
Replace the second-pass occurrence with a marker. This is exactly what Node's util.inspect does.
The number precision trap
JSON has one number type — IEEE-754 doubles. Integers above Number.MAX_SAFE_INTEGER (2^53 - 1 = 9,007,199,254,740,991) lose precision silently:
const json = '{"id": 9007199254740993}'
JSON.parse(json).id // 9007199254740992 — wrong!
The parser correctly produces the closest representable double, which happens to be one less than the original integer. There is no warning. If your IDs come from a database with 64-bit integers (Postgres BIGINT, Snowflake IDs, Twitter status IDs), you have a latent bug.
Fix: Always serialise large integers as strings on the server, then parse them as BigInt or keep them as strings on the client:
{ "id": "9007199254740993" }
Most JSON libraries in strict languages (Java, Go, C#) have this problem too, but JavaScript's lack of integer types makes it especially acute.
The "Unexpected token" error
By far the most common JSON error in practice:
SyntaxError: Unexpected token < in JSON at position 0
The < is the start of <!DOCTYPE html>. Your server returned an HTML error page (a 500, a 502, a login redirect, a captcha challenge), and your client called JSON.parse on it.
The fix is to check the status code *before* parsing:
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
Other "Unexpected token" cases worth knowing:
Unexpected token } in JSON at position N— trailing comma at position N-1Unexpected token ' in JSON at position 0— single quotes instead of doubleUnexpected end of JSON input— the input was truncated (network failure mid-response, or the server didn't send a body)
When the message isn't obvious, paste the JSON into the JSONNeat validator — it'll point to the exact line and column.
Streaming JSON with response.json()
fetch().then(r => r.json()) is not just convenient — it can be faster than response.text().then(JSON.parse) because the engine can start parsing while bytes are still arriving. The difference is small for small payloads but real for multi-megabyte ones.
For truly large payloads (gigabytes), neither approach works because the entire document still has to fit in memory. You need a streaming parser:
stream-json— incremental parser for Node.jsoboe.js— streaming parser for browsers- JSON Lines (NDJSON) — one document per line, processed independently
Performance tips
- For small to medium payloads,
JSON.parseis extremely fast — typically the fastest operation in your request handler. Don't optimise it unless profiling proves it's the bottleneck. JSON.stringifywith a replacer function runs the function for every key/value pair, which is slow on large objects. If you can pre-filter the object first, do that.- Cached replacers and revivers don't help — engines already memoise them.
- For repeated serialisation of the same shape, look at libraries like
fast-json-stringifywhich compile to specialised code given a schema.
Useful patterns
Conditional pretty-printing for development:
const indent = process.env.NODE_ENV === 'development' ? 2 : 0
res.send(JSON.stringify(data, null, indent))
Stripping nullish fields:
JSON.stringify(obj, (key, value) =>
value === null || value === undefined ? undefined : value
)
Sorted keys for deterministic output (caching keys, hashing):
function sortedStringify(obj) {
const keys = []
JSON.stringify(obj, (k, v) => { keys.push(k); return v })
return JSON.stringify(obj, keys.sort())
}
Note that this only sorts top-level keys; for deeply sorted output, a library like json-stable-stringify is the right tool.
Where to go next
If you've used JSON.parse and JSON.stringify for a while without diving into the second and third arguments, today is a good day to do so. They handle 90% of the JSON gymnastics people reach for libraries to do. Combined with structuredClone for deep cloning and a clear understanding of which JavaScript types survive a round-trip, you have everything you need to work with JSON cleanly.
When something goes wrong, validate the JSON first — most "JSON bugs" turn out to be syntax errors that have nothing to do with your code.
Related tools: JSON formatter, validator, minifier.