The error almost always means the value you passed to JSON.parse is not JSON. An HTML error page, an empty response, a double-encoded string, or a truncated body will produce this message. Log the raw text, inspect its first character, and validate it before parsing.
- JSON.parse only accepts a complete JSON text; HTML, JavaScript objects, and empty strings are invalid inputs.
- The fastest diagnosis is to print the raw response text and its first 80 characters before parsing.
- A leading < usually means an HTML error page or a proxy login page, not a bug in your JSON.
- Empty bodies from 204 and 304 responses must be handled before calling JSON.parse.
- Validate and format the payload with a JSON validator before changing application logic.
JSON.parse has a small number of failure modes, but the error messages are easy to misread. The message names a character and a position; it does not tell you where the bad value came from. That distinction matters, because most JSON parse failures are response-handling bugs, not JSON syntax bugs.
This guide shows how to identify the real input, fix the common causes, and prevent the same error from reaching users again.
What the error is actually telling you
The parser reads the first meaningful character and decides what kind of JSON value to expect. A { starts an object, [ starts an array, " starts a string, and t, f, or n start true, false, or null. If the first character is <, the parser cannot start any valid JSON value.
Older V8 versions produce:
SyntaxError: Unexpected token < in JSON at position 0
Newer V8 versions are more explicit:
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Both messages point to the same problem: the input begins with an HTML document. A server error page, a CDN block page, a login redirect, or a reverse proxy maintenance page are the usual sources.
Two details inside the message are worth reading precisely. The older format reports a position in UTF-16 code units from the start of the string, not a byte offset, so it does not match the column numbers in an editor that counts bytes. The newer format quotes a prefix of the input instead of only the offending character; the prefix identifies the document faster than any stack trace. <!DOCTYPE is an HTML page, <?xml is an XML error from a gateway, and a prefix that already looks like JSON means the syntax error is later in the body, not at position 0.
JSON.parse also coerces its argument to a string before parsing, which explains a family of near-miss bugs. JSON.parse(undefined) throws SyntaxError: "undefined" is not valid JSON, JSON.parse({}) throws because the object stringifies to [object Object], and JSON.parse(null) returns null because null stringifies to the four characters null. That last case is the dangerous one: a missing value can pass through the parse step and fail only when business code reads a property from it.
The grammar also permits any JSON value at the top level. RFC 8259 does not require the root to be an object or array, so a response body of true, 42, or "maintenance" parses without an error. If the caller immediately destructures the result, the failure becomes a TypeError: Cannot read properties of true, which looks unrelated to JSON. Parse errors and shape errors are two separate checks, and this guide later treats them separately.
A second common message is:
SyntaxError: Unexpected end of JSON input
That means the parser reached the end of the string while still expecting more characters. The body was empty or truncated.
Diagnose the raw value first
Do not add JSON.parse retries or fallback objects before you know what the response contains. Log the raw text and inspect the start of it.
async function readJson(response) {
const text = await response.text();
console.log({
status: response.status,
contentType: response.headers.get("content-type"),
firstChars: text.slice(0, 120),
length: text.length
});
if (!text.trim()) {
throw new Error(`Empty response body (status ${response.status})`);
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`Invalid JSON from ${response.url}: ${error.message}`);
}
}
This produces evidence instead of a guess. If firstChars starts with <!DOCTYPE html>, the API did not return JSON. If it starts with "{\" and contains escaped quotes, the body is probably double-encoded. If length is zero, the endpoint returned no content.
When the preview still does not identify the source, walk outward from the request:
- Reproduce the call with
curl -iusing the same method, headers, and body. If curl also receives HTML, the fault is in front of the application, not in the JavaScript. - Copy the
x-request-idor trace header from the response and search the server logs for that exact value. - Compare
response.urlwith the URL you requested. A difference means fetch followed a redirect, and the final server may be a login page or a different environment. - Check the decoded body length against
content-length. For an uncompressed response, a short body indicates truncation; for a compressed response,content-lengthdescribes the compressed bytes and says nothing useful about the decoded size. - Only after the raw input is identified should you change the parsing code.
There is a memory cost to this approach. Both response.text() and response.json() buffer the entire body; neither streams. For a 20 MB response, text() holds a UTF-16 string that can occupy twice the transfer size, and JSON.parse then builds the full object graph while the string is still alive, so peak memory is several times the payload size. That is fine for ordinary API responses and wrong for a large export endpoint. The Fetch API also has no standard way to peek at the first bytes and then hand the rest to the parser, so a "just log the start" helper still pays for the whole body. Keep multi-megabyte responses on a dedicated path.
Treat the logged preview as sensitive. An HTML error page can echo request parameters, and a JSON error body can contain an email address or a token. Cap the preview at a fixed length, redact fields you know are secret, and never render it with innerHTML; use textContent, or an error page can turn a debugging aid into an XSS vector.
Cause 1: An HTML error page is being parsed as JSON
A request that fails with a 404, 500, or authentication redirect often returns HTML. Calling response.json() without checking response.ok turns that HTML into a confusing parse error.
const response = await fetch("/api/orders");
if (!response.ok) {
const detail = await response.text();
throw new Error(`API ${response.status}: ${detail.slice(0, 200)}`);
}
const data = await response.json();
Check the status and content type before parsing. If the API is behind a proxy, also check whether the proxy intercepts the request and returns its own HTML page. The fix may be a proxy configuration change, not application code.
A concrete version of this failure: a frontend calls https://api.example.com/orders, the CDN has no route for that path because the service was renamed, and the edge returns its own 502 Bad Gateway page. DevTools shows a 502 with content-type: text/html, and the console shows SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON. The JavaScript never had a chance to be correct.
Status alone is not enough to rule this out. An SSO gateway, a captive portal, or a single-page-app fallback can return HTML with a 200. An Express app with a catch-all app.get("*") route will answer an unknown /api/... path with index.html and a 200, which passes response.ok. Check that the content type starts with application/json before parsing, and register the API's 404 handler before the SPA catch-all so unknown API routes fail as JSON.
Do not add a fallback that returns {} or tries to parse the HTML when the content type is wrong. That converts a routing or gateway failure into missing business data, which is harder to notice and harder to trace. Fail with the status, the URL, and a bounded preview instead.
Cause 2: The response body is empty
204 No Content and 304 Not Modified responses have no body. A successful fetch does not throw for these statuses, so response.json() fails at parse time.
if (response.status === 204) {
return null;
}
For conditional requests, handle 304 separately and reuse the cached value instead of parsing an empty body.
The empty-body family is wider than these two status codes. A HEAD response and a 205 Reset Content are also null-body responses, and response.body === null identifies them before any read. A 200 with Content-Length: 0 is different: the body is an empty stream rather than null, so response.body does not catch it; the text().trim() check does.
The browser cache changes what you observe. During normal revalidation, the browser receives the 304 itself and hands your code the cached 200, so JavaScript never sees the empty response. A 304 becomes visible when the request sets If-None-Match or If-Modified-Since explicitly, or in server-side fetch. In that case response.status is 304, response.ok is false, and a generic if (!response.ok) throw turns a valid cache hit into an error. Decide deliberately whether 304 means "reuse cached value" or "unexpected" for each call site.
Cause 3: The payload is double-encoded
Double encoding happens when a value that is already a JSON string is serialized again.
"{\"id\":42,\"status\":\"ready\"}"
The outer value is a JSON string, not an object. Parsing once returns a string; parsing twice returns the object. Do not add a second JSON.parse in the client as a permanent fix. Find the producer and serialize the object once. The same problem appears when a database column stores JSON as text and an API wraps it again.
To confirm, inspect the parsed type:
const first = JSON.parse(text);
console.log(typeof first); // "string" means the payload is nested
const payload = typeof first === "string" ? JSON.parse(first) : first;
Use the second parse only as a diagnostic, then fix the producer.
Partial double encoding is more common than the whole-body form and is easier to miss. Consider {"id":42,"metadata":"{\"source\":\"etl\",\"version\":2}"}. The top-level parse succeeds, so nothing throws at the boundary. The bug surfaces later as metadata.source === undefined, or as the string [object Object] in a log line after another piece of code coerced metadata to a string. A schema check that requires metadata to be an object catches the violation at the boundary, before the wrong shape spreads into storage or business logic.
Look for the second serialization at the producer. A database text column that stores JSON returns a string, and an ORM may serialize the row again. Java services using Jackson quote a String field that already contains JSON; PHP's json_encode does the same. Both produce perfectly valid JSON that happens to contain serialized JSON, so every consumer has to undo the mistake.
Do not write a recursive helper that parses every string that looks like JSON. A user comment beginning with { is data, and parsing it can change its meaning or throw. Double parsing is correct only when the contract explicitly declares the field to be text-encoded JSON, such as a payload column documented that way.
Cause 4: The JSON is not standard JSON
Standard JSON rejects several constructs that are valid in JavaScript:
| Construct | Valid JavaScript object | Valid JSON |
|---|---|---|
| Single-quoted strings | Yes | No |
| Trailing commas | Yes | No |
| Comments | Yes | No |
| Unquoted keys | Yes | No |
undefined | Yes | No |
NaN and Infinity | Yes | No |
| Leading zero numbers | Yes | No |
If the payload came from a configuration file, a log line, or a hand-edited fixture, validate it with a JSON formatter and validator before parsing. The validator reports the line and column of the first syntax error, which is faster than reading a stack trace.
The parser messages for these mistakes are specific enough to name the fix. A trailing comma in an object produces SyntaxError: Expected double-quoted property name in JSON at position N in current V8. A single-quoted key produces an Unexpected token message naming the quote character. A comment produces an error that names the slash. The reported position counts UTF-16 code units, so a non-ASCII string earlier in the payload shifts it relative to byte-oriented tooling.
Two grammar edge cases cause silent damage instead of an exception. Duplicate keys are legal JSON syntax, and JSON.parse silently keeps the last value: {"role":"user","role":"admin"} becomes {"role":"admin"}. When a gateway and the application use different parsers, they can disagree about which duplicate wins, which is a documented class of authorization bypass. Reject duplicate keys at the trust boundary. Numbers are IEEE 754 doubles, so JSON.parse('{"id":9007199254740993}') returns 9007199254740992; integers above 2^53 cannot be represented exactly. APIs that expose 64-bit snowflake IDs or bigint columns should send them as strings.
Unpaired surrogates are a third quiet edge. The JSON text "\ud800" is syntactically valid and parses to a string containing a lone surrogate. If that value is later encoded to UTF-8 for storage or transport, the replacement character U+FFFD appears, and the original codepoint is gone. Validation should reject unpaired surrogates when the data will be re-encoded.
Cause 5: Truncation, streaming, and encoding issues
A body can be truncated by a network timeout, a misconfigured compression layer, or code that reads a stream twice. A UTF-8 BOM at the start of a file can also produce an unexpected character in older parsers.
const cleaned = text.replace(/^/, "");
const data = JSON.parse(cleaned);
Do not hide truncation with a retry loop. Check the response size, the Content-Length header if present, and the server logs. If the body is large, stream it and parse incrementally with a dedicated streaming parser instead of buffering the entire response.
Truncation has a recognizable signature. A Node service that crashes after flushing half of a serialized object leaves the client with SyntaxError: Unexpected end of JSON input, and the status line can still read 200 because the headers were sent before the crash. A proxy read timeout cuts the body at the timeout boundary, so the received length is suspiciously round, for example exactly 30,000 bytes from a 30-second limit. When the response is uncompressed, compare the decoded text length with content-length; when content-encoding: gzip is present, content-length counts compressed bytes and cannot be compared with the decoded string at all.
The byte-order mark needs a precise note. RFC 8259 says implementations must not add a BOM and may ignore one. V8's JSON.parse does not ignore it, so parsing a string that starts with U+FEFF throws. The Fetch API hides the problem because it decodes the body with the Encoding Standard's UTF-8 decoder, which removes a leading BOM; response.text() and response.json() therefore succeed. The same file read with fs.readFileSync(path, "utf8") keeps the BOM and fails. That difference explains payloads that parse in the browser and fail in Node, and it is why the manual strip in the snippet above still has a place.
When the payload is too large to buffer, JSON.parse is the wrong tool rather than a slow one. Newline-delimited JSON can be split on newlines and parsed record by record; a single large document needs a streaming parser that emits events as it walks the structure. Both approaches trade the simplicity of one parse call for backpressure, partial-failure handling, and a different way of reporting error positions. On the browser main thread, parsing a multi-megabyte string also blocks rendering, so a payload large enough to drop frames belongs in a Web Worker even after it is buffered.
A reusable safe parser
The following helper handles the cases above without disguising real failures:
type JsonResult<T> =
| { ok: true; value: T }
| { ok: false; reason: "empty" | "html" | "invalid"; preview: string };
export async function parseJsonResponse<T>(response: Response): Promise<JsonResult<T>> {
const text = await response.text();
const trimmed = text.trim();
if (!trimmed) return { ok: false, reason: "empty", preview: "" };
if (trimmed.startsWith("<")) return { ok: false, reason: "html", preview: trimmed.slice(0, 120) };
try {
return { ok: true, value: JSON.parse(trimmed) as T };
} catch {
return { ok: false, reason: "invalid", preview: trimmed.slice(0, 120) };
}
}
The caller can then decide whether to retry, show an error, or fall back. The important part is that the failure reason is visible in logs and metrics.
Two refinements matter in production. trim() allocates a copy of the whole string; for very large inputs, inspect a short prefix and let JSON.parse handle leading whitespace, which it already accepts. The reason codes should also feed metrics separately: a rising html count points at a gateway or authentication layer, while a rising invalid count on a known endpoint points at a producer sending malformed data. If both are counted as a generic parse error, two unrelated incidents look identical on a dashboard.
Decide what each reason means for the caller. An HTML login page after a 401 is not retryable; an empty 204 is a normal result; a truncated body may deserve one retry on a fresh connection. Encoding that policy in the result contract, instead of at every call site, keeps behavior consistent when a new consumer is added.
Verify the fix
After changing the response handling, test four cases:
- A normal JSON response with status 200.
- An HTML error page with status 500.
- An empty 204 response.
- A truncated body.
Use the JSON viewer to inspect nested structures and the JSON Schema validator to confirm the parsed object has the expected shape. A parse success only proves the text was JSON; it does not prove the data is valid for your application.
A regression suite for the helper should cover at least nine cases: a normal 200 with application/json; a 200 with text/html from an SPA fallback; a 500 with an HTML error page; a 204 with no body; a 304 from a conditional request; a 200 with Content-Length: 0; a truncated body; valid JSON with the wrong shape; and a network failure, where fetch rejects before any response object exists. The last two are the ones teams forget, because a parse success says nothing about shape and a network error never reaches the parser. In Node 18 and later, each response case can be constructed with new Response(body, { status, headers }), so the helper is testable without a server.
For the shape check, required and type catch missing and mistyped fields. additionalProperties: false looks stricter but turns every field the server adds later into a client failure; use it only for contracts you control end to end, not for third-party APIs that evolve.
When to repair JSON instead of rejecting it
Repair is appropriate for user-provided data that must be salvaged, such as pasted logs or exported files. It is not appropriate for API responses, where silent repair can hide a contract violation. For one-off repair, use a JSON cleaner and review every change. For production, reject invalid input with a clear error message and keep the original text for debugging.
One more boundary: never repair payloads that participate in authentication, signatures, or payments. A repaired trailing comma can change which duplicate key wins, and a repair that drops an unparseable fragment can turn a request that should have been rejected into one that succeeds with different meaning. In those paths, a parse failure is evidence of tampering or a version mismatch and must stay visible.
Keep the original text alongside the repaired output. When a user reports a failure, the repaired JSON alone cannot show what was wrong; the raw input, with secrets redacted, plus a record of each repair, is what lets you verify the fix and add a producer-side guard later.
The rule is simple: make the invalid input visible, fix the producer, and only then add convenience handling at the boundary.
Frequently asked questions
What does "Unexpected token < in JSON at position 0" mean?
It means the first non-whitespace character of the input is <, so the parser encountered HTML or XML instead of a JSON value. The usual cause is an HTML error page, a login redirect, or an API gateway response being parsed as JSON.
Why does JSON.parse throw "Unexpected end of JSON input"?
The input ended before a complete JSON value was parsed. Common causes are a truncated response, a stream that was read only once, an empty string, or a request that returned 204 No Content.
Can I use JSON.parse on a JavaScript object?
No. JSON.parse expects a string containing JSON text. If you already have an object, use it directly; if you have a string representation created with a non-JSON format, fix the producer instead of wrapping it in JSON.parse.
How do I validate JSON before parsing it in production?
Parse inside a try/catch, check response.ok and the content type, handle empty bodies explicitly, and log a bounded slice of the raw text on failure. For one-off inspection, paste the payload into a JSON formatter and validator.
Does a trailing comma cause a JSON parse error?
Yes. Standard JSON does not allow trailing commas, single-quoted strings, comments, or unquoted keys. Those are JavaScript object literal features, not JSON features.