How to Fix "Unexpected Token" JSON Errors in JavaScript
The "Unexpected token < in JSON at position 0" error looks like a parsing bug, but it is almost always a lie about your data: the server sent HTML when your code asked for JSON. Here is how to read it, and how to fix every variant.
Unexpected token < in JSON at position 0 is one of the most misleading errors in JavaScript, because it points at the wrong crime scene. The message is about JSON parsing, so the instinct is to go hunting for a malformed comma or a stray quote in your data. But in the overwhelming majority of cases there is nothing wrong with your JSON — because there is no JSON. The server sent back an HTML page, and your code tried to parse <!DOCTYPE html> as if it were data. The < at position 0 is the first character of that HTML document. Once you internalise that one fact, this error goes from mysterious to obvious.
What the error actually means
Unexpected token < in JSON at position 0 is a SyntaxError thrown by JSON.parse() — the function response.json() calls internally. It fires when the text being parsed isn't valid JSON. A leading < means the body starts with HTML, so the server returned a web page (an error, redirect, or 404) instead of the JSON your code expected.
The name of the token in the message is a precise hint about what arrived instead of JSON. < is HTML. o as in Unexpected token o in JSON at position 1 means you called JSON.parse on the word [object Object] — you parsed something that was already an object. Unexpected end of JSON input means the string was empty, so the parser ran out of characters before it found anything. Each variant names the exact byte that broke it, and that byte tells you which of the causes below you're looking at. (Older engines and V8 word the message slightly differently — Unexpected token '<', "<!DOCTYPE "... is not valid JSON — but it is the same error.)
Why is a leading < illegal but a leading space is fine? Because JSON's grammar is deliberately tiny. The ECMA-404 standard and its canonical twin at json.org define a JSON text as a single value — an object, array, string, number, or one of true, false, null — optionally wrapped in insignificant whitespace, where whitespace means only tab, line feed, carriage return, and space. Nothing else is legal at the top level. Per MDN's JSON.parse() reference, strings must be delimited by double quotes, there are no comments (neither // nor /* */), and — unlike JavaScript object literals — no trailing commas are allowed. So the instant the parser meets a <, a ', or a comment, it has left the grammar and throws a SyntaxError.
The causes, with code
In rough order of how often they show up in real bug reports:
// The endpoint 404s and your host returns an HTML error page
const res = await fetch('/api/usre'); // typo in the URL
const data = await res.json();
// SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
// The body was actually:
// <!DOCTYPE html><html><head><title>404 Not Found</title>...
// res.json() tried to parse that HTML and choked on the first '<'.// Calling .json() without checking the response first
async function load() {
const res = await fetch('/api/user');
return res.json(); // runs even on a 500 or an auth redirect
}
// A failed request can still resolve with an HTML error body.
// fetch() only rejects on network failure, NOT on 4xx/5xx —
// so res.json() happily tries to parse the error page.// A 204 No Content (or an empty DELETE response) has no body
const res = await fetch('/api/session', { method: 'DELETE' });
const data = await res.json();
// SyntaxError: Unexpected end of JSON input
// There are zero characters to parse, so JSON.parse('') throws.// Parsing something that is already a JavaScript object
const res = await fetch('/api/user');
const obj = await res.json(); // already parsed to an object
const data = JSON.parse(obj); // parsing it a second time
// SyntaxError: Unexpected token o in JSON at position 1
// (obj gets coerced to the string "[object Object]")// Invalid JSON literals in a hand-authored string
JSON.parse("{ 'name': 'Ada' }"); // single quotes are not valid JSON
JSON.parse('{ "name": "Ada", }'); // trailing comma is not valid JSON
JSON.parse('\uFEFF{"ok":true}'); // a BOM/preamble before the '{'
// JSON requires double-quoted keys and strings, no trailing commas,
// and nothing before the opening brace or bracket.The first two causes account for the vast majority of the <-at-position-0 reports. The last three are the tail: an empty body from a no-content response, a value that was already parsed, or a JSON string a human typed by hand with a habit borrowed from JavaScript object literals (single quotes and trailing commas are legal in JS, illegal in JSON). The BOM case is sneaky — a byte-order mark (U+FEFF) is an invisible character that some editors and servers prepend to UTF-8 text, and because it sits before the opening brace, the parser rejects it too.
Match the message to the fix
Because each variant names the exact byte that broke, the error message alone usually narrows the diagnosis to one row. Read the message, find it here, apply the fix:
| Feature | Most likely cause | Correct fix |
|---|---|---|
| Unexpected token < ... position 0 | Server sent HTML — a 404 page, 500 error page, or an auth login redirect | Check response.ok + Content-Type before .json(); read the body as text and look at it |
| Unexpected end of JSON input | Empty body (204 No Content, an empty DELETE, or a handler that returned nothing) | Guard the empty string after trimming; return null or a default instead of parsing |
| Unexpected token o ... position 1 | Double-parsing a value that was already an object ([object Object]) | Use res.json() OR JSON.parse(text) — never both; drop the redundant parse |
| Unexpected token ' / bad control character | Hand-written JSON with single quotes, trailing commas, comments, or a leading BOM | Use double quotes, remove trailing commas/comments, strip the BOM; lint with a validator |
| Valid-looking body still fails .json() | Response is JSON-ish but the Content-Type header is text/plain or text/html | Fix the server header, or if you trust the source read res.text() and JSON.parse it yourself |
See the real response before you parse
The single most useful debugging move is to stop guessing what the body contains and look at it. Open DevTools and go to the Network tab, then click the failing request. Per Chrome's Network panel documentation, clicking a request reveals sub-tabs including Headers (the status code and Content-Type), Response (the raw source), and Preview (a rendered view — genuinely handy when an API hands back an HTML error page, because the rendered page is easier to read than the source). Recent Chrome even prints human-readable text next to the HTTP status code so you can tell a 401 from a 500 at a glance. Or, in code, read the body as text before you parse it, so the error stops hiding the evidence:
const res = await fetch('/api/user');
const raw = await res.text(); // read as text, don't parse yet
console.log(res.status, res.headers.get('content-type'));
console.log(raw.slice(0, 200)); // the first 200 chars of the body
// If you see "<!DOCTYPE html>" here, the server sent a page, not data.Note that you can only read a Response body once. If you call .text() you can't then call .json() on the same response — the stream is consumed and bodyUsed flips to true. That's fine, because the robust pattern below reads it as text exactly once and parses that string itself.
A robust fetch-then-parse helper
The fix that prevents every variant above is a small wrapper that checks the status and content type, reads the body as text once, guards the empty case, and only then parses — turning the useless SyntaxError into an error that actually tells you what happened. This mirrors what the Node.js Fetch guide recommends too: check the status before you trust the body, and never leave a response body unconsumed.
async function fetchJson(url, options) {
const res = await fetch(url, options);
// 1. fetch() doesn't reject on 4xx/5xx — check it yourself.
const raw = await res.text(); // read the body once, as text
if (!res.ok) {
throw new Error(
`Request to ${url} failed: ${res.status} ${res.statusText}\n` +
`Body: ${raw.slice(0, 300)}`,
);
}
// 2. Empty body (e.g. 204 No Content) -> no JSON to parse.
if (raw.trim() === '') return null;
// 3. Wrong content type usually means an HTML page slipped through.
const type = res.headers.get('content-type') || '';
if (!type.includes('json')) {
throw new Error(
`Expected JSON from ${url} but got "${type}". ` +
`Body starts with: ${raw.slice(0, 80)}`,
);
}
// 4. Now it's safe to parse — and if it still fails, the message
// includes the raw text, not just "Unexpected token".
try {
return JSON.parse(raw);
} catch (err) {
throw new Error(`Invalid JSON from ${url}: ${raw.slice(0, 120)}`);
}
}Every branch here replaces a silent, confusing failure with a specific one. A 500 no longer parses its own error page — it throws with the status and a snippet of the body. An empty response returns null instead of crashing. An HTML page with the wrong Content-Type is rejected with the first bytes of what actually arrived. And a genuinely malformed JSON payload throws an error that quotes the raw text, so you can see the trailing comma or the BOM instead of decoding position 0 by hand. Note the content-type check uses includes('json') rather than an exact application/json match — that deliberately accepts JSON media types like application/vnd.api+json and application/ld+json, which a stricter check would wrongly reject.
Caveats: when the client-side fix isn't the whole story
The helper above stops your app from crashing, but be honest about what it does and doesn't fix. It is a client-side guard, not a cure. If your server genuinely returns an HTML page on a 500, the real fix lives on the server — configure the API to always emit a JSON error body with the right Content-Type, so clients get structured errors instead of markup. The wrapper only makes that failure legible; it doesn't make the endpoint correct.
A few more sharp edges. Returning null for an empty body is a choice, not a law — a caller that expects an object will now get null and may throw one line later; decide per-endpoint whether empty should be null, {}, or a thrown error. The content-type test trusts the server's header, and some misconfigured backends send text/plain for perfectly good JSON; if you control both ends and trust the source, reading res.text() and calling JSON.parse yourself is a legitimate escape hatch. And reading the body as text before parsing forgoes the tiny streaming optimisation of a direct .json() call — irrelevant for typical API payloads, but worth knowing if you're piping very large responses. None of these are reasons to skip the guard; they're reasons to tune it to your API.
Why it's so hard to reproduce
This error has a nasty habit of appearing only in production, or only for some users, and never on your machine. The reason is that the trigger lives in the response, not the code. Locally you hit a dev server that returns clean JSON for every route. In production the same request might get a login redirect because a session expired, a CDN maintenance page during a deploy, a framework 404 because a route was renamed, or a 500 that only fires under real load. The JavaScript is byte-for-byte identical; the body it received is not. That's why staring at the stack trace rarely helps — the bug isn't in the parsing line, it's in what the network handed that line.
Which means the fastest way to close one of these reports is to see the actual HTTP response the user got, not to re-derive it from the error message. The SyntaxError deliberately throws away the one thing you need — the raw body — and reduces it to position 0. If you can recover that body, the diagnosis is usually a five-second read: a login page means auth, a 404 page means a bad URL, a stack-trace page means a server crash.
This is exactly the gap a captured session closes. When BugMojo records a report, it captures the network requests alongside the console error — so the failing request's real response body, status code, and headers are right there next to the SyntaxError. Instead of asking the user to reproduce it or re-running the request yourself hoping to catch the same state, you open the recording and read the exact HTML the server returned in place of JSON. The thing the error throws away is the thing the capture keeps — and because redaction runs in the browser before anything is uploaded, sensitive values in that captured response are scrubbed client-side first.
Install the free BugMojo extension and capture the exact session — replay, console, and the full network response — when the SyntaxError happens, so you read the real body instead of decoding "position 0".
Install the free extensionFrequently asked questions
Frequently asked questions
Sources
- JSON.parse() — MDN reference: parses a JSON string and throws a SyntaxError if the text is not valid JSON — MDN Web Docs (2026)
- Response.json() — MDN reference: reads the response stream to completion and parses the body text as JSON — MDN Web Docs (2026)
- SyntaxError — MDN reference: thrown when the engine encounters tokens or token order that does not conform to the syntax — MDN Web Docs (2026)
- Content-Type — MDN reference: the header indicating the media type of the resource, e.g. application/json vs text/html — MDN Web Docs (2026)
- Trailing commas — MDN: arrays and objects in JSON cannot have trailing commas, unlike JavaScript literals — MDN Web Docs (2026)
- ECMA-404: The JSON Data Interchange Syntax (2nd edition) — the formal grammar for valid JSON texts — Ecma International (2017)
- Introducing JSON — the canonical grammar: double-quoted strings, no comments, no trailing commas — json.org (2026)
- Inspect network activity — Chrome DevTools: Headers, Response and Preview tabs for a request — Chrome for Developers (2026)
- Using the Fetch API with Undici in Node.js — status checks and response.json() in Node — Node.js (2026)
Get bug-tracking insights, weekly.
Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.

