What position 0 actually means
A JSON document can only start with one of seven things: an opening brace, an opening bracket, a double quote, a minus sign, a digit, or the letters t, f and n that begin true, false and null. A strict parser reads the first non-whitespace character, checks it against that list, and stops immediately if it does not match.
An angle bracket matches nothing on that list. So the parser stops before reading a value, reports position 0, and tells you nothing about your data — because it never saw your data. The response is not malformed JSON. It is not JSON.
That distinction is the whole fix. People spend hours looking for a stray comma in a payload they never received. The payload was fine; the response was somebody else's error page.
What actually arrived
Seven causes account for almost every case. The second column is the part that matters, because it decides whether your code can tell anything went wrong at all.
| Cause | Body you received | How to recognise it |
|---|---|---|
| The route does not exist | An HTML 404 page | Status 404, and the body starts with <!DOCTYPE or contains “Cannot GET”. |
| The handler threw | An HTML 500 page | Status 500. In development, most frameworks return a stack trace page. |
| Single-page-app rewrite | index.html, with status 200 | Any unknown path is rewritten to the app shell. The status is 200 and nothing looks wrong. |
| Auth redirect | The login page | The request was followed by fetch, so you get the redirect target and its 200. |
| Reverse proxy or CDN error | An nginx, Cloudflare or load-balancer page | 502, 503 or 504 with a header like server: nginx. The app never saw the request. |
| Missing or extra trailing slash | A 301 to the slashless path, then that page | Check the response URL, not the one you requested. |
| Wrong base URL | Whatever lives at that host root | A typo in a base URL, or http instead of https, lands on the marketing site. |
Notice that only the first, second and fifth rows come with a non-200 status. The other four are the interesting ones, and the rewrite is the worst of them.
The 200 that lies
A static host deployed with a catch-all rewrite — the standard single-page-app configuration — answers every unknown path with the app shell and a 200. So a request to a path that does not exist returns a perfectly successful-looking response whose body begins <!DOCTYPE html>.
In that case fetch does not reject, and res.ok is true. The only thing that fails is res.json(), and its message names a character rather than the actual problem. Checking the status code cannot save you here, because the status code is correct.
What separates the two cases is the content type. It is the one signal that is still telling the truth, which is why the fix below checks it first and reads the body as text second.
How to see the body
The fastest route is the one that shows headers and body together. From a terminal:
curl -i -H 'Accept: application/json' https://api.example.com/v1/ordersThe -i flag prints the status line and headers before the body, and the first line of the body is normally enough to identify the page. If you would rather not re-run the request, res.clone() gives you a second readable stream:
const text = await res.clone().text();
console.log(res.status, res.headers.get("content-type"));
console.log(text.slice(0, 200));In the browser, the network panel's Response tab shows the raw bytes. The Preview tab tries to render them, which is helpful for JSON and actively misleading for an error page — it can make a 404 page look like a successfully rendered document.
The messages that look alike
These were captured by running each input through Node 22.22.2, not paraphrased. The same failure has at least three different wordings depending on the engine and its version:
| Engine | Message | What it means |
|---|---|---|
| Node 22 / current V8 | Unexpected token '<', "<!DOCTYPE "... is not valid JSON | The current wording. It shows the first ten characters instead of a position. |
| Older Chrome, Safari | Unexpected token < in JSON at position 0 | The wording most search results and Stack Overflow answers use. |
| Firefox | JSON.parse: unexpected character at line 1 column 1 of the JSON data | Same failure, phrased as a line and column. |
| Any engine, empty body | Unexpected end of JSON input | A 204 No Content, or a proxy that stripped the body. |
| Any engine, BOM first | Unexpected token '', "{"a":1}" is not valid JSON | An invisible U+FEFF byte before the brace. Note the empty-looking quote. |
| Any engine, plain-text error | Unexpected token 'E', "Error: not found" is not valid JSON | A text/plain error body, often from a hand-written server handler. |
Captured on 29 October 2026. Parser messages are not part of the JSON specification, so an engine update can change the wording without changing behaviour.
The fix that holds
Everything above collapses into one rule: never call .json() on a response you have not inspected. Read the text, decide whether it is JSON, and parse it yourself so the failure message contains evidence.
async function readJson(res) {
const text = await res.text();
const type = res.headers.get("content-type") ?? "";
if (!type.includes("json")) {
throw new Error(
`${res.status} ${res.url} returned "${type || "no content-type"}": ` +
text.slice(0, 120),
);
}
return JSON.parse(text);
}The important part is text.slice(0, 120). The next time this happens, the error message contains the beginning of the page that came back — <!DOCTYPE html><html><head><title>404 or a Cloudflare banner or a login form. That is a cause, not a character.
Two smaller habits worth keeping: treat a 204 as success with no body rather than calling .json() on it, and strip a leading U+FEFF before parsing anything that came from a file rather than the network.
Where to look on the server
- The catch-all is registered before the API route. In Express, a catch-all route (
app.get("*")in version 4,app.get("/*splat")in version 5) or a static handler placed above the router swallows the request and serves the shell. - The error handler returns HTML. A framework's default error page does exactly that. If the request accepted JSON, the handler should answer with JSON too.
- The rewrite rule covers too much. A host-level rewrite of
/*to/index.htmlmust exclude the API prefix. - Auth is handled by a redirect. A session-based login guard answers an expired token with a 302 to the login page. APIs should answer 401 and let the client decide.
- A proxy in between owns the error. If the body is stamped nginx or Cloudflare, the application never ran. Look at the upstream health, not the code.
Once you have the body, the cause is usually one of these five, and the fix is in routing or headers rather than in any JSON.
Related reading
If the response really was JSON and still failed, the shape of the message changes: it names a position inside a document it managed to read. The guide to parser messages covers those, and the JSON5 guide covers the trailing comma and comment complaints that arrive with a position attached.