← Blog · Written by the JSONNeat maintainer · Published

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, becomes null in arrays
  • Date — serialised to an ISO string; parse won't restore it without a reviver
  • Map, Set — become {}
  • Functions and class instances — omitted (lose all methods)
  • BigInt — throws TypeError: 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-1
  • Unexpected token ' in JSON at position 0 — single quotes instead of double
  • Unexpected 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.js
  • oboe.js — streaming parser for browsers
  • JSON Lines (NDJSON) — one document per line, processed independently

Performance tips

  • For small to medium payloads, JSON.parse is extremely fast — typically the fastest operation in your request handler. Don't optimise it unless profiling proves it's the bottleneck.
  • JSON.stringify with 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-stringify which 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.


Written by the maintainer of JSONNeat. Questions or corrections? Email [email protected].

Related tools: JSON formatter, validator, minifier.