{}JSONLint.app

Unexpected token < in JSON: Fixing fetch() Errors

9 min read
Share:

If you are seeing Unexpected token < in JSON at position 0 (or the newer "<!DOCTYPE ... is not valid JSON"), your code did not receive broken JSON. It received an HTML page and tried to parse it as JSON. This is one of the most common runtime errors in front-end and back-end HTTP code, and once you understand the mechanics, it takes about thirty seconds to diagnose.

Decoding the message

The parser reports the first character it cannot reconcile with valid JSON. Position 0 is the very first byte of the body. A JSON value can only start with {, [, ", a digit, -, t, f, or n. A < at position 0 means the body begins with something like:

<!DOCTYPE html>
<html>
  <head><title>404 Not Found</title></head>
  ...

The < is the opening of an HTML tag. You asked the JSON parser to read an HTML document, and it failed on the very first character.

The newer V8 engines (Node 20+, recent Chrome) changed the wording. Instead of Unexpected token < in JSON at position 0, you now get a message that quotes the offending prefix:

SyntaxError: "<!DOCTYPE "... is not valid JSON

Same root cause, friendlier message — it literally shows you the start of the HTML.

Sibling variants

The same class of bug produces different first-character errors depending on what the server returned:

  • Unexpected token 'I', "Internal S"... is not valid JSON — the server printed a plain-text Internal Server Error body. The I is the offending byte.
  • Unexpected token 'N', "Not Found" is not valid JSON — a plain-text Not Found with no HTML wrapper.
  • Unexpected token 'O', "OK" is not valid JSON — a health-check or load-balancer endpoint that returns the literal string OK.
  • Unexpected end of JSON input — an empty body. A 204 No Content, or an error with no body, gives you a zero-length string, and JSON.parse("") throws. This is the empty-body cousin of the same problem.

In every case the lesson is identical: the body was never JSON, and you parsed it without checking.

The real causes

The body is HTML or plain text because of where the request actually landed:

  1. A 404 or 500 returning an HTML error page. Express, Nginx, Apache, and most frameworks serve a styled HTML error page by default. Your fetch succeeded at the network level — you got a response — but the status is 404 or 500 and the body is HTML.
  2. An auth redirect to a login page. Your session expired, the server issued a 302 to /login, the browser followed it transparently, and you received the login page's HTML with a 200 status. This is especially nasty because res.ok is true.
  3. A CDN, proxy, or rate-limit page. Cloudflare, an API gateway, or a WAF intercepts the request and returns its own HTML ("Checking your browser", "429 Too Many Requests", "Access Denied").
  4. The wrong URL — usually the SPA's index.html. A misconfigured route or a relative path means /api/users resolves to a path your single-page-app server doesn't recognize, so its catch-all handler returns index.html. Every API call gets the same HTML shell, and the < you see is <!DOCTYPE html>.
  5. Calling .json() on a 204. A successful DELETE or PUT often returns 204 No Content with an empty body — that's the "Unexpected end of JSON input" variant.

The debugging walkthrough

Stop calling res.json() blindly. When you hit this error, the fastest path to the answer is to read the body as text and look at it.

const res = await fetch('/api/users');
console.log('status:', res.status, res.ok);
console.log('content-type:', res.headers.get('content-type'));

const text = await res.text();        // read as text, not json
console.log('first 200 chars:', text.slice(0, 200));

Run that and the console tells you everything:

status: 404 false
content-type: text/html; charset=utf-8
first 200 chars: <!DOCTYPE html><html><head><title>Error</title></head><body><pre>Cannot GET /api/users</pre></body></html>

There it is. The status is 404, the content type is text/html, and the body is the Express "Cannot GET" page. You were never going to parse this as JSON. Now you know the real bug: the route is wrong, the server isn't running, or you're hitting the SPA host instead of the API host.

The three things to check, in order:

  1. res.ok — is the status in the 200–299 range? If not, the body is almost certainly an error page.
  2. res.headers.get('content-type') — does it actually say application/json? If it says text/html, do not parse it as JSON.
  3. The first ~200 chars of res.text() — the body itself names the problem ("Not Found", a login form, a Cloudflare challenge).

Before and after

The naive version that produces the cryptic error:

// Fragile: parses whatever came back, even an HTML error page
fetch(url)
  .then(r => r.json())          // throws "Unexpected token <" on a 404 page
  .then(data => render(data));

A robust wrapper checks the status and content type, and when something is wrong it throws an error that contains the real status code and a snippet of the actual body — so the next person to hit it sees the cause immediately, not a position-0 mystery:

async function fetchJson(url, options) {
  const res = await fetch(url, options);

  // 204 / 205 have no body by definition
  if (res.status === 204 || res.status === 205) return null;

  const contentType = res.headers.get('content-type') || '';
  const body = await res.text();   // read once, as text

  if (!res.ok) {
    throw new Error(
      `HTTP ${res.status} for ${url} — body starts: ${body.slice(0, 120)}`
    );
  }

  if (!contentType.includes('application/json')) {
    throw new Error(
      `Expected JSON from ${url} but got "${contentType}". ` +
      `Body starts: ${body.slice(0, 120)}`
    );
  }

  try {
    return JSON.parse(body);
  } catch (e) {
    throw new Error(`Invalid JSON from ${url}: ${e.message}. Body: ${body.slice(0, 120)}`);
  }
}

Now a 404 throws HTTP 404 for /api/users — body starts: <!DOCTYPE html>... instead of Unexpected token < in JSON at position 0. The error points straight at the cause.

The Python requests equivalent

The same mistake in Python raises requests.exceptions.JSONDecodeError (a subclass of json.JSONDecodeError since requests 2.27):

import requests

r = requests.get(url)
r.json()   # raises JSONDecodeError: Expecting value: line 1 column 1 (char 0)

"Expecting value: line 1 column 1 (char 0)" is Python's wording for the exact same position-0 problem. Check before you parse:

import requests

r = requests.get(url)

if not r.ok:
    raise RuntimeError(
        f"HTTP {r.status_code} for {url} — body starts: {r.text[:120]!r}"
    )

ctype = r.headers.get("content-type", "")
if "application/json" not in ctype:
    raise RuntimeError(
        f'Expected JSON from {url} but got "{ctype}". Body starts: {r.text[:120]!r}'
    )

data = r.json()

r.raise_for_status() is the shortcut for the status check, but it won't catch the auth-redirect case where you got a 200 with HTML — for that you still need the content-type guard.

The Go equivalent

Go's encoding/json produces its own version of the message. Unmarshalling an HTML body gives:

invalid character '<' looking for beginning of value

Same <, same position 0. The idiomatic fix is to read the body fully, check the status and content type, then unmarshal:

resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
    return err
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    return fmt.Errorf("HTTP %d for %s — body starts: %.120s",
        resp.StatusCode, url, body)
}

ctype := resp.Header.Get("Content-Type")
if !strings.Contains(ctype, "application/json") {
    return fmt.Errorf("expected JSON from %s but got %q — body starts: %.120s",
        url, ctype, body)
}

var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
    return fmt.Errorf("invalid JSON from %s: %w — body: %.120s", url, err, body)
}

Reading the body fully with io.ReadAll before unmarshalling is what lets you put the real HTML snippet into the error message.

Edge cases worth knowing

UTF-8 BOM at position 0. A byte-order mark (U+FEFF) is invisible but real. If a file or response begins with a BOM, even perfectly valid JSON throws, because the BOM sits at position 0 and is not whitespace:

{"valid": true}

(Imagine an invisible U+FEFF byte before the {.) The fix is to strip it before parsing:

const clean = text.replace(/^/, '');
JSON.parse(clean);

Go's json.Unmarshal does not skip a leading BOM either, so trim the bytes 0xEF 0xBB 0xBF from the front. Python's json.loads rejects a BOM too — open files with encoding="utf-8-sig" to drop it automatically.

Double-encoded or gzipped bodies. If you read a gzipped response without letting the HTTP client decompress it, the "text" is binary garbage and position 0 is a non-JSON byte. Most clients (fetch, requests, Go's http.Client) handle gzip transparently, but a manually set Accept-Encoding header without matching decompression breaks this. Likewise, a value that was JSON-stringified twice arrives as a JSON string ("{\"a\":1}") and needs parsing twice — though that one starts with ", not <.

Old vs. new V8 wording. If you are debugging across environments, remember the message text changed. Older Node and Chrome say Unexpected token < in JSON at position 0. Node 20+ and recent Chrome say "<!DOCTYPE "... is not valid JSON. They are the same error; tutorials and Stack Overflow answers written for one version still apply to the other.

The takeaway

Unexpected token < is not a JSON-formatting bug — it is an HTTP-handling bug. The body was an HTML error page, a login redirect, a proxy challenge, or your SPA's index.html. Never call .json(), r.json(), or json.Unmarshal on a response you haven't validated. Check the status, check the content type, and put a snippet of the real body in your error message. Once you can see the first 200 characters, the cause is always obvious.

Tools

  • JSON Validator — paste the response body you captured and get the exact line, column, and reason it fails to parse.
  • JSON Error Analyzer — turns a raw parser error like "Unexpected token <" into a plain-language explanation of what went wrong.
  • JSON Formatter — pretty-print a body once you confirm it actually is JSON, to inspect its structure.
  • JSON Unescape — unwrap a double-encoded JSON string before validating it.

Learn More

Related Articles