Unexpected token '<', "<!DOCTYPE "... is not valid JSON
This is not a malformed-JSON problem. A request that was supposed to return JSON returned an HTML document instead, and the parser failed on the very first character of it.
What the message is telling you
V8 (Chrome, Edge, Node.js) reports the failure like this:
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSONThree parts matter. Unexpected token '<' is the first character the parser read. "<!DOCTYPE "... is an excerpt of what you actually passed in. is not valid JSON is the verdict on the whole string, not on one position within it.
That excerpt is the reason this error is hard to act on. V8 prints the input verbatim only when it is 20 characters or shorter; anything longer is truncated to the first 10 characters. Since every HTML document starts with the same 10 characters — <!DOCTYPE plus a space — every occurrence of this error looks identical, whether the response was a 404 page, a login form, or a gateway timeout. The message has already discarded the part you need. Stop reading it and go read the response.
Why < always means HTML, never JSON
A JSON text can only begin with one of a handful of characters, after optional whitespace:
{— object[— array"— string0-9 or -— numbert / f / n— true, false, or null
< is not among them, so the parser fails at offset 0 without reading further. In practice a leading < means an HTML document: <!DOCTYPE html>, or sometimes <html> or <?xml. Something returned a web page where your code expected data.
Note that the parser is doing its job correctly. Nothing is wrong with your JSON, because you never had any. The bug is upstream, in what the request returned.
What actually returned HTML
Five causes account for nearly all of these. The first two are by far the most common.
- 1. The URL 404sA typo, a stale path, or a missing leading slash resolves to a route that does not exist, and the server answers with its HTML 404 page. Relative paths are the usual culprit: fetch("api/users") from /settings/profile requests /settings/api/users.
- 2. An auth redirect returned the login pageThe session or bearer token expired, the server replied 302 to /login, and fetch followed the redirect transparently. The response you parse is the login page — status 200, content-type text/html.
- 3. A proxy, gateway, or CDN answered instead of your appNginx, a load balancer, Cloudflare, or an API gateway returns its own styled 502/503/504 page when the upstream is down or times out. The body is HTML even though your application would have returned JSON.
- 4. A dev-server catch-all served index.htmlSPA dev servers rewrite unmatched paths to index.html so client-side routing works. An API call that misses the proxy configuration gets the app shell back with a 200 status.
- 5. The server errored and rendered an HTML error pageFramework debug pages (Rails, Laravel, Django, Flask) render stack traces as HTML on a 500. The real failure is server-side; the JSON error is downstream noise.
Two of these produce a 200 OK — the auth redirect and the dev-server catch-all — which is why checking res.ok alone does not catch every case.
Find the real response
In DevTools, open the Network panel and reload with the panel open so the request is captured. Filter to Fetch/XHR, select the failing request, and read three things in order:
- Status — a 404, 500, or 502 names the cause immediately. A 200 points at a redirect or a catch-all route.
- Response headers —
content-type: text/htmlconfirms the server never intended to send JSON. - The Response tab, not the Preview tab — Preview renders HTML as a page, which hides what you are looking at. Response shows the raw bytes.
Also check the request URL as sent. If it is not the URL you wrote, a relative path resolved against the current route. If a redirect occurred, Chrome shows the chain in the Headers tab; you can also surface it in code:
const res = await fetch("/api/users", { redirect: "manual" });
// res.type === "opaqueredirect" instead of silently returning the login pageOn the command line, curl -i shows status, headers, and body together, and curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' prints just the two fields that decide this.
Fixing it in code
The pattern that produces this error throws the evidence away:
// Assumes the response is JSON. It usually is — until it isn't.
const res = await fetch("/api/users");
const users = await res.json();
// SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSONRead the body as text first, then decide. This keeps the response available for the error message, which turns a generic SyntaxError into a report naming the URL, the status, and what came back:
async function fetchJson(url, init) {
const res = await fetch(url, init);
const body = await res.text(); // read once, as text
if (!res.ok) {
throw new Error(
`${init?.method ?? "GET"} ${url} -> ${res.status} ${res.statusText}\n` +
`content-type: ${res.headers.get("content-type")}\n` +
body.slice(0, 500),
);
}
try {
return JSON.parse(body);
} catch {
// Keep the body: this is the part the SyntaxError throws away.
throw new Error(
`Expected JSON from ${url} but got ` +
`${res.headers.get("content-type")}:\n${body.slice(0, 500)}`,
);
}
}A response body can only be consumed once, which is why this reads text() and then parses the string, rather than calling json() and falling back to text() in the catch — that second read throws a TypeError about the body already having been read (the exact wording differs between Node.js and browsers). If you need both, call res.clone() before the first read.
This is a guard for debugging, not a substitute for the fix. Once the message tells you the request 404s or lands on a login page, correct the URL, the proxy rule, or the auth handling.
A different message means a different problem
If your error does not mention <, nothing above applies. These messages are from V8 13.6 (Node.js 24):
Unexpected end of JSON input- The body was empty. Common on 204 No Content, a HEAD request, or a response body already consumed by an earlier .json() / .text() call.
Unexpected non-whitespace character after JSON at position N- Valid JSON followed by more content — usually two payloads concatenated, or a log line appended to the response.
Expected double-quoted property name in JSON at position N- Genuinely malformed JSON. Typically a trailing comma before } or ], which JSON does not allow.
"undefined" is not valid JSON- The string "undefined" was passed to JSON.parse — usually a variable that was never assigned, stringified on the way in.
The last three are genuine JSON problems — the payload arrived, but it is malformed. Those are worth pasting into a validator. The <!DOCTYPE case is not. For the property-name error in particular, one message covers three different mistakes.
Older wording and other engines
Searching this error turns up two different strings for the same failure. Before V8 rewrote these messages in May 2022 (issue 6551, “JSON.parse errors made user-friendly”), the same response produced:
SyntaxError: Unexpected token < in JSON at position 0Same cause, same fix. The rewrite added the input excerpt and dropped the bare offset. Advice written against the older string is still valid — just note that position 0 and the "<!DOCTYPE "... excerpt are two descriptions of one event.
Firefox uses a different format entirely and always has: SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data. If you are reconciling bug reports across browsers, these three strings all describe the same HTML-instead-of-JSON response.
Checking the body once you have it
When you have copied the raw response out of the Network panel and want to confirm what it is, paste it into the JSON Formatter. HTML fails validation immediately with the offending first character shown. Valid JSON pretty-prints — which tells you the response was fine and the fault is elsewhere, often a second request you had not noticed failing.
Parsing runs in your browser, so an error response containing session identifiers or internal hostnames is not uploaded anywhere.