Skip to main content
Workflow · 8 min read

Unexpected Token < in JSON: What It Means

It almost always means your API returned HTML, not JSON. How to confirm it in one command, and what every other JSON.parse error actually means.

There is one error that every developer who has touched an API has seen:

SyntaxError: Unexpected token < in JSON at position 0

It is worth knowing two things about it immediately. First, the fix is almost never in your JSON — there is no JSON. Second, that exact message is out of date, and if you are still seeing it you are on an old runtime.

What `<` actually tells you

Position 0 means the very first character. The first character is <. Nothing in the JSON grammar starts with <, but one very common thing does: HTML.

Your code asked for JSON and received a web page. The usual culprits, in rough order of frequency:

What came backWhy
<!DOCTYPE html> — an error pageThe server returned 500 or 502 and a human-readable error page with it. Your code never checked the status.
A login pageThe session expired, and the API redirected to a sign-in form instead of returning 401.
A 404 pageWrong URL, or a typo in the path — the server answered with its not-found page.
Your own index.htmlA single-page-app dev server falling back to the shell for an unmatched route. Very common when the API proxy is misconfigured.
A CDN or WAF challenge pageCloudflare, a bot check, or a captcha interstitial.

Confirm it in one command

Stop guessing at the response and look at it:

# Status and content type, without the body getting in the way
curl -s -o /dev/null -D - https://api.example.com/items | head -20

# The first bytes of whatever actually came back
curl -s https://api.example.com/items | head -c 300

If the second command prints <!DOCTYPE html>, you have your answer and you can stop debugging the parser.

The fix in code

const res = await fetch(url);

if (!res.ok) {
  // Read as text — the body is probably an error page, not JSON.
  throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
}

const type = res.headers.get('content-type') || '';
if (!type.includes('application/json')) {
  throw new Error(`Expected JSON, got ${type}: ${(await res.text()).slice(0, 200)}`);
}

return res.json();

Those two guards turn an unreadable Unexpected token < into an error that names the status and shows you the first 200 characters of what actually arrived. That is the difference between a five-minute fix and an afternoon.

The message has improved — yours may be stale

V8 rewrote these errors, so modern Node and Chrome tell you far more. Same bad input, two runtimes:

RuntimeMessage
Older V8Unexpected token < in JSON at position 0
Node 22 / V8 12.4Unexpected token '<', "<!DOCTYPE "... is not valid JSON

The new one quotes the offending content back at you, which is exactly the information the old one withheld. If you are searching for the old string and finding only old answers, that is why — and it is a hint that upgrading your runtime would make every future parse failure easier to read.

Every other message, and what it means

Measured on Node 22. These cover essentially all real-world parse failures:

InputJSON.parse saysActual problem
empty bodyUnexpected end of JSON inputA 204, an empty error body, or a response you already consumed.
{'a': 1}Expected property name or '}' ... position 1Single quotes. Not valid JSON.
{"a":1,}Expected double-quoted property name ... position 7Trailing comma.
{"a":1} // noteUnexpected non-whitespace character after JSONA comment. JSON has none.
{"a":NaN}Unexpected token 'N' ...NaN, Infinity and undefined are not JSON values.
{"a":1Expected ',' or '}' after property valueTruncated — a stream cut short or a partial read.
[object Object]"[object Object]" is not valid JSONAn object was coerced to a string somewhere. Usually string concatenation where JSON.stringify was meant.
a BOM then {...}Unexpected token '\ufeff' ...A byte-order mark, usually from a file saved by a Windows editor. Strip it before parsing.

That [object Object] row catches people repeatedly. It means the value was already turned into a string by template interpolation or String() before it reached the parser — nothing is wrong with the data, only with where it was stringified.

Getting a better error than the runtime gives you

Even the improved V8 messages describe the grammar rather than the mistake. Pasting the document into the JSON validator gives the same failures in plainer terms, with a line, a column and a suggested fix:

InputWhat you get here
{'a': 1}Single quotes are not valid in JSON. — JSON strings must use double quotes: "value". *(line 1, column 2)*
{"a":1,}Trailing comma before the closing brace. — Remove the comma after the last property. *(line 1, column 7)*
{"a":1Unexpected end of input: an object is never closed. — Add the missing "}". *(line 1, column 7)*
{"a":{"b":[1,2}}Mismatched bracket: found "}" while an array is still open. *(line 1, column 15)*

That last one is the case worth having a tool for. A bracket closed in the wrong order inside a large document is nearly impossible to find by eye, and the runtime will only tell you that something went wrong somewhere.