BugMojoBugMojoBugMojo
FeaturesPricingBlogHelpAbout
Add to ChromeLog inGet started
BugMojoBugMojo

Bug reports that actually help fix bugs — capture, replay, share.

A product of Softech Infra.

Product

  • Features
  • Pricing
  • Browser extension
  • Get started
  • Log in

Resources

  • Help & guides
  • Blog
  • Compare
  • Glossary

Company

  • About
  • Contact
  • Security
  • Privacy
  • Terms
  • Sitemap
© 2026 BugMojo. All rights reserved.
AllGuidesEngineeringPlaybooksCompareGlossaryAlternativesBy roleBug tracking by framework
  1. Home
  2. Blog
  3. Guides
  4. How to Fix "Unexpected Token" JSON Errors in JavaScript
Guide

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.

Hrishikesh BaidyaHrishikesh Baidya·Jul 16, 2026·11 min read
Guides
Isometric line-art browser with a console JSON SyntaxError and a network panel showing an HTML response where JSON was expected, linked in lime, on a dark canvas
TL;DR
  • What it means: JSON.parse() (usually via response.json()) tried to parse a string that isn't valid JSON and threw a SyntaxError.
  • The load-bearing clue: Unexpected token < in JSON at position 0 means the first character was < — the server sent HTML (an error page, login redirect, or 404/500 body), not JSON.
  • Other variants: Unexpected end of JSON input = empty body; Unexpected token o = you parsed an already-parsed object; hand-written JSON with trailing commas or single quotes fails too.
  • Fix it: read the body as text first, check response.ok and the Content-Type, guard empty bodies, and never double-parse.

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:

1-server-returned-html.jsjavascript
// 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 '<'.
2-no-ok-or-content-type-check.jsjavascript
// 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.
3-empty-body.jsjavascript
// 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.
4-double-parsing.jsjavascript
// 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]")
5-hand-written-json.jsjavascript
// 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:

FeatureMost likely causeCorrect fix
Unexpected token < ... position 0Server sent HTML — a 404 page, 500 error page, or an auth login redirectCheck response.ok + Content-Type before .json(); read the body as text and look at it
Unexpected end of JSON inputEmpty 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 1Double-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 characterHand-written JSON with single quotes, trailing commas, comments, or a leading BOMUse 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/htmlFix the server header, or if you trust the source read res.text() and JSON.parse it yourself
The token named in the SyntaxError maps almost 1:1 to a root cause.

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:

inspect-raw-response.jsjavascript
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.

fetch-json.jsjavascript
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.

Tip

Remember that fetch() only rejects on a network-level failure — DNS, connection refused, CORS. A 404 or 500 is a successful fetch with an unsuccessful status, which is exactly why unchecked .json() calls parse error pages. If you're also seeing the request never complete at all, that's a different problem — see how to fix "Failed to fetch".

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.

Key takeaway

The honest takeaway: Unexpected token < in JSON is not a JSON bug — it's a response.json() call parsing an HTML page the server sent instead of data. Read the body as text first, check response.ok and the Content-Type before parsing, guard empty bodies, and never re-parse an already-parsed value. The message hides the real response; your job is to recover it.

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.

⁓ ⁓ ⁓
See the HTML the server actually sent

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 extension

Frequently asked questions

Frequently asked questions

Sources

  1. JSON.parse() — MDN reference: parses a JSON string and throws a SyntaxError if the text is not valid JSON — MDN Web Docs (2026)
  2. Response.json() — MDN reference: reads the response stream to completion and parses the body text as JSON — MDN Web Docs (2026)
  3. SyntaxError — MDN reference: thrown when the engine encounters tokens or token order that does not conform to the syntax — MDN Web Docs (2026)
  4. Content-Type — MDN reference: the header indicating the media type of the resource, e.g. application/json vs text/html — MDN Web Docs (2026)
  5. Trailing commas — MDN: arrays and objects in JSON cannot have trailing commas, unlike JavaScript literals — MDN Web Docs (2026)
  6. ECMA-404: The JSON Data Interchange Syntax (2nd edition) — the formal grammar for valid JSON texts — Ecma International (2017)
  7. Introducing JSON — the canonical grammar: double-quoted strings, no comments, no trailing commas — json.org (2026)
  8. Inspect network activity — Chrome DevTools: Headers, Response and Preview tabs for a request — Chrome for Developers (2026)
  9. Using the Fetch API with Undici in Node.js — status checks and response.json() in Node — Node.js (2026)
Share:
Hrishikesh Baidya
Hrishikesh Baidya· Chief Technology Officer

Hrishikesh Baidya is the CTO at Softech Infra. He is drawn to architecture that is invisible — systems that simply work — and leads the engineering behind BugMojo.

On this page

  • What the error actually means
  • The causes, with code
  • Match the message to the fix
  • See the real response before you parse
  • A robust fetch-then-parse helper
  • Caveats: when the client-side fix isn't the whole story
  • Why it's so hard to reproduce

Get bug-tracking insights, weekly.

Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.