Skip to content
DevTinyTools

Errors

Unexpected token '<' in JSON at position 0: what your API actually sent

A parser that fails at position 0 read nothing at all. Whatever arrived is not slightly malformed JSON; it is a different document entirely, and the message names only the character it tripped over. Here is how to find out what really came back, and why a 200 response is the worst case.

8 min readTool: JSON Validator

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.

Causes of an HTML body where JSON was expected
CauseBody you receivedHow to recognise it
The route does not existAn HTML 404 pageStatus 404, and the body starts with <!DOCTYPE or contains “Cannot GET”.
The handler threwAn HTML 500 pageStatus 500. In development, most frameworks return a stack trace page.
Single-page-app rewriteindex.html, with status 200Any unknown path is rewritten to the app shell. The status is 200 and nothing looks wrong.
Auth redirectThe login pageThe request was followed by fetch, so you get the redirect target and its 200.
Reverse proxy or CDN errorAn nginx, Cloudflare or load-balancer page502, 503 or 504 with a header like server: nginx. The app never saw the request.
Missing or extra trailing slashA 301 to the slashless path, then that pageCheck the response URL, not the one you requested.
Wrong base URLWhatever lives at that host rootA 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/orders

The -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:

Verbatim parser messages for an HTML or empty body
EngineMessageWhat it means
Node 22 / current V8Unexpected token '<', "<!DOCTYPE "... is not valid JSONThe current wording. It shows the first ten characters instead of a position.
Older Chrome, SafariUnexpected token < in JSON at position 0The wording most search results and Stack Overflow answers use.
FirefoxJSON.parse: unexpected character at line 1 column 1 of the JSON dataSame failure, phrased as a line and column.
Any engine, empty bodyUnexpected end of JSON inputA 204 No Content, or a proxy that stripped the body.
Any engine, BOM firstUnexpected token '', "{"a":1}" is not valid JSONAn invisible U+FEFF byte before the brace. Note the empty-looking quote.
Any engine, plain-text errorUnexpected token 'E', "Error: not found" is not valid JSONA 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.html must 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.

Frequently asked questions

Is position 0 always the first character of the response?
Yes — after leading whitespace is skipped. A parser that reports position 0 never consumed a value, so the response is not JSON with a mistake in it; it is a different document. That is why editing the JSON you think you sent does nothing.
The status is 200 and the content-type says application/json. How?
A proxy or a server framework can set the header before the error page replaces the body, and a catch-all rewrite can serve an HTML shell with a 200. Trust the body more than the header: if the first byte is a brace or a bracket, it is JSON; if it is an angle bracket, it is not, whatever the header claims.
Why does this never happen in Postman?
Usually because Postman shows you the raw response and the status side by side, so a 404 page is obvious. fetch hides that: the promise resolves on a 404, res.ok is a separate check, and only res.json() fails. Reproduce it in the browser once and the cause is normally visible immediately.
Does an empty response cause the same message?
No, it is a different one: Unexpected end of JSON input. An empty string has no character to be unexpected. This matters for 204 No Content, which is a correct response for a DELETE, so a client that always calls res.json() fails on a successful request.
How do I find the request that returned HTML?
Open the network panel, filter to Fetch/XHR, and sort by status. Click the failing row and read the Response tab — not Preview, which tries to render the body and can hide the fact that it is a page. The first line of the response is usually enough to name the cause.
Can a byte-order mark alone cause it?
Yes. A file saved by a Windows editor can begin with U+FEFF. It is invisible in every editor, it is not whitespace to a strict JSON parser, and the message quotes what looks like an empty string. Strip it, or read the file as text and remove the first character if it is U+FEFF.