Unexpected token < in JSON: Fixing fetch() Errors
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-textInternal Server Errorbody. TheIis the offending byte.Unexpected token 'N', "Not Found" is not valid JSON— a plain-textNot Foundwith no HTML wrapper.Unexpected token 'O', "OK" is not valid JSON— a health-check or load-balancer endpoint that returns the literal stringOK.Unexpected end of JSON input— an empty body. A204 No Content, or an error with no body, gives you a zero-length string, andJSON.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:
- A 404 or 500 returning an HTML error page. Express, Nginx, Apache, and most frameworks serve a styled HTML error page by default. Your
fetchsucceeded at the network level — you got a response — but the status is 404 or 500 and the body is HTML. - An auth redirect to a login page. Your session expired, the server issued a
302to/login, the browser followed it transparently, and you received the login page's HTML with a200status. This is especially nasty becauseres.okistrue. - 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").
- The wrong URL — usually the SPA's
index.html. A misconfigured route or a relative path means/api/usersresolves to a path your single-page-app server doesn't recognize, so its catch-all handler returnsindex.html. Every API call gets the same HTML shell, and the<you see is<!DOCTYPE html>. - Calling
.json()on a 204. A successfulDELETEorPUToften returns204 No Contentwith 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:
res.ok— is the status in the 200–299 range? If not, the body is almost certainly an error page.res.headers.get('content-type')— does it actually sayapplication/json? If it saystext/html, do not parse it as JSON.- 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.
Related Tools & Resources
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
- JSON Parse Errors — the full catalog of
JSON.parsefailures and what each message means. - Fix "Unexpected end of JSON input" — the empty-body cousin of this error, common with 204 responses.
- Mastering JSON in JavaScript — safe parsing patterns,
try/catch, and working withfetchresponses. - JSON Syntax Errors — the rules for what a valid JSON value may start with, so you know why
<is rejected.
Related Articles
Formatting JSON: When It Helps, When It Hurts
A practical guide to JSON beautifiers: pretty-print for humans, minify for transport, sort keys for diffs, and the lossy gotchas of round-tripping through stringify.
Common JSON Mistakes and How to Avoid Them
The 20 most common JSON syntax errors developers make, organized by category with examples and fixes.
How to Fix 'Unexpected End of JSON Input' Error
Learn what causes the 'Unexpected end of JSON input' error and how to fix it with practical solutions for JavaScript, Python, and other languages.
How to Open JSON Files: A Practical Guide
Open, view, and troubleshoot JSON files on macOS, Windows, and Linux with real commands for jq, Python, VS Code, and browsers — plus fixes for common errors.