One note on why a proxy company is writing about JSON.parse. We are Geonode and we sell proxies, and the single most common SyntaxError our customers report has nothing to do with JSON at all — it is Unexpected token '<', which means the response was HTML rather than JSON, which means the API returned a block page, a login redirect or an error page. So here is the honest disclaimer: if JSON.parse is throwing on data you fetched, log the raw response body before you change anything else. Nine times out of ten the parser is working perfectly and reporting accurately that you were sent a web page. Buying proxies fixes that only in the specific case where you were blocked; it does nothing for a wrong URL, an expired token or a rate limit you should be respecting. Print the response first.
With that out of the way, the actual API.
The Basics and the Errors You Will Actually Hit
const data = JSON.parse('{"name": "Ada", "born": 1815}');
// { name: "Ada", born: 1815 }
Two parameters: the text, and an optional reviver function. That is it.
It throws SyntaxError when the input violates JSON grammar, and JSON grammar is stricter than JavaScript object literal syntax in ways that catch people out. Four cases account for nearly every failure.
Single quotes. MDN is explicit: "JSON strings must be delimited by double (not single) quotes." Valid JavaScript, invalid JSON.
JSON.parse("{'name': 'Ada'}"); // SyntaxError
JSON.parse('{"name": "Ada"}'); // fine
Trailing commas. Legal in modern JavaScript, illegal in JSON:
JSON.parse("[1, 2, 3, 4, ]"); // SyntaxError
Unquoted keys. {name: "Ada"} is a fine object literal and not JSON. Keys must be quoted strings.
The response was not JSON. The one described above. Unexpected token '<' means the body started with <, which means HTML. Unexpected end of JSON input usually means an empty body — a 204, a truncated response, or a fetch you forgot to await.
Wrap it, always, and log what you actually received:
function parseOrThrow(text, url) {
try {
return JSON.parse(text);
} catch (err) {
throw new Error(
`Failed to parse JSON from ${url}: ${err.message}. ` +
`First 200 chars: ${text.slice(0, 200)}`
);
}
}
The text.slice(0, 200) is the part that matters. A bare SyntaxError tells you the parse failed; the first 200 characters tell you why, and usually the answer is visible immediately.
The Reviver Function and What It Is For
The second argument transforms values as they are parsed:
const data = JSON.parse(text, (key, value) => {
if (key === "created") return new Date(value);
return value;
});
Three behaviours worth knowing precisely.
It runs depth-first. Nested properties are visited before their parents, and the final call uses an empty string as the key for the root value. So by the time your reviver sees an object, its children have already been through it.
Returning undefined deletes the property. MDN: "If the reviver function returns undefined (or returns no value), the property is deleted from the object." This is easy to trigger by accident — a reviver with a conditional branch that falls off the end returns undefined and silently removes keys. Always return value explicitly as the default.
The root can be replaced entirely. "If you return another value from reviver, that value will completely replace the originally parsed value. This even applies to the root value."
The classic use is date revival, since JSON has no date type:
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
const data = JSON.parse(text, (key, value) =>
typeof value === "string" && ISO_DATE.test(value)
? new Date(value)
: value
);
Be careful with the pattern matching. A reviver that converts anything date-shaped will convert strings you wanted to keep as strings — version numbers, identifiers, user content that happens to look like a timestamp. Prefer matching on the key name where you know the schema.
Also note the cost: the reviver is called once per value in the document. On large payloads this is a real performance consideration, and it is often cheaper to parse plainly and transform only the fields you care about afterwards.
Source Text Access: context.source and JSON.rawJSON
This is the most significant recent addition and many developers have not encountered it yet.
The JSON.parse source text access proposal reached stage 4 of the TC39 process, meaning it is approved for the standard. It solves a problem the proposal states directly: "Transformation between ECMAScript values and JSON text is lossy."
The reviver now receives a third argument for primitive values. MDN describes context.source as "The original JSON string representing this value" — and the proposal is more precise, calling it the source text "inclusive of punctuation but exclusive of leading/trailing insignificant whitespace", alongside index, input and keys.
Why this matters becomes obvious with one example:
const text = '{"id": 9007199254740993}';
JSON.parse(text).id;
// 9007199254740992 — wrong, silently
JSON.parse(text, (key, value, context) =>
key === "id" ? BigInt(context.source) : value
).id;
// 9007199254740993n — correct
Without source access, the number has already been converted to a JavaScript double by the time your reviver sees it. The precision is gone before you can intervene. context.source gives you the original digits.
The proposal also adds JSON.rawJSON(), which lets you supply raw JSON text that JSON.stringify emits unchanged — completing the round trip so a BigInt read from JSON can be written back without corruption.
JSON.stringify({ id: JSON.rawJSON("9007199254740993") });
// '{"id":9007199254740993}'
Check support for your target environments before relying on it, but this is now the correct answer to the large-number problem rather than a workaround.
Number Precision Is the Bug You Will Ship
Worth its own section because it fails silently and the symptoms appear far from the cause.
JSON numbers become JavaScript numbers, which are IEEE 754 doubles. Integers above Number.MAX_SAFE_INTEGER — 9,007,199,254,740,991 — cannot all be represented exactly. MDN puts it plainly: numbers "may lose precision in the process".
The dangerous property is that nothing throws. You get a number. It is simply not the number that was sent.
JSON.parse('{"id": 12345678901234567890}').id;
// 12345678901234567000
Where this bites in practice:
- Database identifiers. 64-bit integer primary keys exceed the safe range. Two distinct records can parse to the same JavaScript number.
- Snowflake-style IDs. Used by several large platforms, routinely above the limit.
- Financial amounts in minor units. Large sums in cents or satoshis.
- Timestamps in nanoseconds. Any nanosecond epoch value since 1970 is already outside the safe range.
Three mitigations, in order of preference:
Ask for strings. If you control the API, serialise large identifiers as strings. This is the cleanest fix, it works everywhere, and it costs nothing. MDN recommends exactly this: "One way to transfer large numbers without loss of precision is to serialize them as strings, and revive them to BigInts."
Use context.source with a reviver. As above, when you do not control the producer and your environment supports it.
Use a JSON library with BigInt support. For older environments, several parsers handle this. It costs a dependency and some performance.
What does not work: checking whether the number "looks right" after parsing. By then the information is gone, and a wrong value is indistinguishable from a right one.
__proto__ and Prototype Pollution
MDN identifies the one case where JSON and JavaScript diverge in meaning: "The only instance where a piece of JSON text represents a different value from the same JavaScript expression is when dealing with the \"__proto__\" key."
In a JavaScript object literal, __proto__ sets the prototype. In JSON.parse, it creates an ordinary own property:
const fromLiteral = { __proto__: { admin: true } };
fromLiteral.admin; // true — prototype was set
const fromJson = JSON.parse('{"__proto__": {"admin": true}}');
fromJson.admin; // undefined — plain own property
Object.hasOwn(fromJson, "__proto__"); // true
So JSON.parse itself is safe here — this is deliberate and correct behaviour.
The danger is what happens next. Prototype pollution vulnerabilities almost always occur when parsed data is merged into another object by code that does not filter dangerous keys:
// Unsafe: a naive deep merge can walk into Object.prototype
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
merge(target[key] ?? (target[key] = {}), source[key]);
} else {
target[key] = source[key];
}
}
}
Feed that a payload containing __proto__ and you can modify Object.prototype for the whole program. Defences:
Filter the dangerous keys explicitly — __proto__, constructor, prototype — in any merge or assignment that touches untrusted data.
Use Object.create(null) for objects that hold untrusted keys, so there is no prototype to pollute.
Use Map where you are really building a key-value store rather than a structured object.
Validate against a schema. The general answer, and the one that catches other problems too. Parsing and validation are separate steps and both are necessary.
JSON.parse Versus eval Versus Response.json()
Never use eval. It executes arbitrary code, it is slower for this purpose, and it accepts things that are not JSON. There is no case where eval is the right tool for parsing JSON.
Response.json() is what you want when working with fetch. It reads the body and parses in one step:
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
The res.ok check is the part people skip, and skipping it is the direct cause of the Unexpected token '<' error from the introduction. fetch does not reject on HTTP error statuses — a 403 or a 500 resolves normally, and then .json() tries to parse an error page. Check the status first, and check content-type if you want to be thorough:
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
const type = res.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
const body = await res.text();
throw new Error(`Expected JSON, got ${type}: ${body.slice(0, 200)}`);
}
const data = await res.json();
Note that Response.json() does not accept a reviver. If you need one, use res.text() followed by JSON.parse.
Libraries differ here too — some parse automatically and throw on non-2xx statuses, which changes where your error handling belongs. We compared the behaviour in axios vs fetch.
Parsing Large JSON Without Freezing the Page
JSON.parse is synchronous and blocking. On the main thread, parsing a large document freezes the interface for its duration — this is a common and easily diagnosed cause of jank.
Rough guidance: below a megabyte, do not think about it. Between one and ten, measure on your slowest target device. Above ten, do something else.
The options, in increasing order of effort:
Move it to a Web Worker. The simplest real fix. Parse off the main thread and post the result back. Note that transferring the result has its own structured-clone cost, so this helps most when the worker also does subsequent processing.
Ask for less data. Pagination, field selection, a narrower endpoint. Almost always the correct answer and almost always skipped because it requires talking to whoever owns the API.
Use a streaming parser. Libraries exist that emit values as they arrive rather than building the whole tree. Worth it when documents are genuinely large or when you only need part of the payload.
Use newline-delimited JSON. For large collections, one JSON document per line is dramatically easier to process incrementally, since each line parses independently and a truncated stream still yields complete records. If you control the format, this is frequently the better design — and the trade-offs against other formats are covered in our comparison of JSON and CSV.
When JSON.parse Is the Wrong Tool
When the input is not JSON. JSON5, JSONC, and configuration files with comments and trailing commas all need their own parsers. JSON.parse will reject them correctly and you should not attempt to strip comments with a regular expression — that path leads to a parser you did not mean to write.
When you need validation, not just parsing. Successful parsing tells you the syntax was valid. It says nothing about whether the required fields exist or have the right types. Parse then validate; a schema validation library is the right tool for the second step and try/catch is not.
When the data must round-trip losslessly. Large integers, dates, undefined, functions, Map, Set, NaN, Infinity — none survive JSON intact. If lossless round-tripping is a requirement, either use context.source and JSON.rawJSON deliberately, or use a format designed for it.
When you are parsing on every render. Parsing the same string repeatedly in a hot path is pure waste. Parse once and cache.
When the string came from a fetch you did not check. The one we opened with, restated because it is the most common of all. If JSON.parse is throwing on fetched data, the bug is upstream. Check the status code, check the content type, log the body. The parser is telling you the truth.
People Also Ask
What does JSON.parse do?
It converts a JSON-formatted string into a JavaScript value — object, array, string, number, boolean or null. It takes an optional reviver function that can transform each value as it is parsed. It throws SyntaxError if the input is not valid JSON.
Why does JSON.parse say "Unexpected token '<'"?
Because the string starts with <, which means you received HTML rather than JSON — usually an error page, a login redirect or a block page. The parser is correct; the request is the problem. Log the first 200 characters of the response body and the cause is normally obvious.
How do I parse JSON with large numbers in JavaScript?
Use the reviver's context.source argument to read the original digits and construct a BigInt, since by the time the value reaches your reviver it has already lost precision. Better still, if you control the API, serialise large identifiers as strings.
What is the reviver function in JSON.parse?
An optional second argument called for every key-value pair, depth-first, ending with the root under an empty-string key. Whatever it returns replaces the value; returning undefined deletes the property. Its usual job is converting strings to richer types such as Date.
Is JSON.parse safe?
Against code execution, yes — unlike eval, it never executes anything. It also handles __proto__ safely, creating a plain own property rather than setting the prototype. The risk is in what you do afterwards: merging untrusted parsed data into other objects without filtering __proto__ and constructor is how prototype pollution happens.
What is the difference between JSON.parse and Response.json()?
Response.json() reads a fetch response body and parses it in one step, and it does not accept a reviver. JSON.parse works on a string you already have. Note that fetch does not reject on HTTP errors, so check res.ok before calling .json() or you will parse an error page.
Can JSON.parse handle comments or trailing commas?
No. Both are invalid JSON and both throw SyntaxError. If your input has them it is JSON5 or JSONC, and it needs a parser for that format rather than a regular expression that strips them.
Does JSON.parse block the main thread?
Yes, it is synchronous. For documents under a megabyte this is irrelevant; for large payloads it causes visible freezing. Move parsing to a Web Worker, request less data, or use a streaming parser.
Wrapping Up
JSON.parse has a two-parameter signature and a surprising amount of depth behind it. The parts worth carrying away are the ones that fail quietly rather than loudly.
Number precision is the most serious: large integers are corrupted silently, nothing throws, and the wrong value is indistinguishable from the right one until something downstream breaks. The fix is either string-encoded identifiers at the source or the reviver's context.source argument, now at stage 4 and part of the standard.
The reviver deserves more use than it gets, particularly for dates, with the caveat that forgetting to return value on the default path silently deletes properties. And prototype pollution is not a JSON.parse problem at all — the function handles __proto__ correctly — but it is a problem for whatever merges the result, which is close enough to matter.
Everything else reduces to one habit: when parsing fails on data you fetched, log the raw body before changing any code. The error message almost always contains the answer, and it is usually that you never received JSON in the first place.