A support ticket I have seen more than any other, in different disguises:
"The API works in Postman. In our app it throws Unexpected token '<' in JSON at position 0."
Every time, the same thing has happened: the app never received JSON. It received an HTML page, and the parser choked on the first character before it could learn anything about the payload.
That distinction sounds trivial. It costs people an afternoon, because the error message points at a character in a document that was never the problem.
Position 0 means "I never read a value"
A JSON document can only begin with one of seven things:
{ [ " - 0-9 t (true) f (false) n (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 gives up before reading a value, reports position 0, and has no information about your data — because it never touched your data. The response is not malformed JSON. It is not JSON.
Which means: editing the payload will do nothing. The bug is upstream of the payload.
What actually arrived
Seven causes cover almost every case. The middle column is the part worth memorising, because it decides whether your code can even tell that something went wrong.
Cause
Body you received
How to recognise it
Route does not exist
HTML 404 page
Status 404, body starts with <!DOCTYPE or contains Cannot GET
Handler threw
HTML 500 page
Status 500, in development usually a stack trace
SPA catch-all rewrite
index.html, status 200
Any unknown path returns the app shell
Auth redirect
The login page
fetch followed the 302, so you get its 200
Reverse proxy / CDN
nginx, Cloudflare or LB page
502–504, server: nginx header, app never ran
Trailing slash mismatch
A 301, then the page
Check res.url, not the URL you requested
Wrong base URL
Whatever lives at that host
A typo, or http where you meant https
Only three of those come with a non-200 status. The other four are the interesting ones.
The 200 that lies
Deploy a static host with the standard single-page-app rewrite and every unknown path answers with the app shell and a 200.
So a request for a route that does not exist returns a response that looks perfectly healthy. fetch resolves. res.ok is true. res.statusText is OK.
The only thing that fails is res.json(), and its message blames a character.
This is the case where checking the status code cannot save you, because the status code is correct. What still tells the truth is the content-type header — which is why the fix below checks it before it parses anything.
Three ways to see the body in ten seconds
From a terminal. -i prints the status line and headers above the body, which is normally all you need:
curl -i -H 'Accept: application/json' https://api.example.com/v1/orders
Without re-running the request. 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 DevTools. Network panel, filter to Fetch/XHR, sort by status, open the failing row, read the Response tab. Not the Preview tab — Preview tries to render the body, and it will happily render a 404 page as if it were a document.
The rule that prevents the next one
Never call .json() on a response you have not inspected. Read the text, decide whether it is JSON, parse it yourself — so that when it fails, the error message contains evidence instead of a character.
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 part that matters is text.slice(0, 120). The next time this happens, your log contains <!DOCTYPE html>
Top comments (1)
One case the post skips, because it only appears when the redirect is a 301 or 302:
fetch follows it with GET, not POST.
So a POST to an endpoint with an expired session lands on the login page as a GET,
comes back 200 / text/html, and
res.json()fails on<. The original body isgone — you can't even see what you sent.
If a body has to survive a redirect, the server must answer 307 or 308. Or better,
not redirect at all: an API should answer 401 and let the client decide, which is
also the only version that works when the client is not a browser.
Did anyone hit the nastier variant — content-type says
application/json, body isstill an HTML page? I've only seen it behind a proxy that stamped the header
before the upstream error page replaced the body, and I'd like to know if it
happens elsewhere.