“Unexpected token < in JSON at position 0”

Position 0 with a < is the least ambiguous error JSON can hand you: the very first byte your code tried to parse was an angle bracket, which means the body was never JSON — it was markup. Almost always that means an HTML error page came back where you expected data. Paste the response below and the tool confirms it and shows you exactly what arrived.

Indent

Paste the raw response, credentials and all. There is no upload endpoint, no storage and no share link on this page — the parser runs in your tab. A debugging payload full of tokens goes no further than your own browser. Why that is worth checking on any tool you use →

Searching a different exact string? This page covers Unexpected token < … position 0 specifically. See also “Unexpected end of JSON input”, “JSON.parse: unexpected character” (the Firefox/Safari wording), or the full token-by-token reference covering all sixteen causes.

What position 0 actually rules out

RFC 8259, the JSON specification, defines a document as optional whitespace, one value, then optional whitespace — and a value must begin with {, [, a quote, a digit, a minus sign, or the first letter of true, false or null. < is not on that list under any circumstance. So when the parser reports position 0 and names <, it is not describing a subtle syntax slip — it is telling you the document is a different format altogether. In practice that is always markup: an HTML page beginning <!DOCTYPE html> or <html>, or occasionally XML beginning <?xml.

The exact wording depends on the engine and, for V8, on the version:

EngineMessage
V8, older — Chrome < 118, Node < 20 Unexpected token < in JSON at position 0
V8, current — Chrome, Node, Edge, Deno Unexpected token '<', "<!DOCTYPE "... is not valid JSON
SpiderMonkey — Firefox JSON.parse: unexpected character at line 1 column 1 of the JSON data
JavaScriptCore — Safari, Bun JSON Parse error: Unrecognized token '<'

All four are the same fault. If you searched the classic wording and landed here from an older Chrome or Node process, or you are seeing the Firefox or Safari phrasing for the exact same problem, you are in the right place — the cause and the fix below apply regardless of which line your console printed.

Confirm it in under a minute

Before chasing any specific cause, get direct evidence. Three checks, in order of how fast they rule things out:

  1. Log the raw response body before parsing it. Not the object your framework hands back after its own processing — the exact text.
    const res  = await fetch(url);
    const text = await res.text();
    console.log(res.status, res.headers.get('content-type'));
    console.log(text.slice(0, 300));
    const data = JSON.parse(text);
    If that first console.log prints <!DOCTYPE html> or <html>, the investigation is over: something upstream is serving a page, not your API.
  2. Check the status code. A 404, 500, 502, 503 or 401 travelling with an HTML body is the overwhelmingly common case. A 200 with HTML is rarer and more specific — usually a login redirect or an SPA fallback, both covered below.
  3. Check Content-Type. A JSON API response header reads application/json. text/html confirms the diagnosis independently of what the body actually contains.

Why fetch never throws on a 404 or a 500

This is the detail that makes the bug reach JSON.parse at all instead of being caught earlier. fetch’s promise rejects only for a genuine network failure — DNS not resolving, the connection being refused, a CORS preflight being blocked. A response that arrives with a 404, 500 or any other HTTP status is, as far as fetch is concerned, a completely successful exchange: a server answered, and here is its answer. res.ok is false for anything outside 200–299, but nothing checks it for you. Skip that check and the error status sails straight past, the HTML error body gets handed to JSON.parse, and the failure only surfaces two lines later with a message that says nothing about a 404.

The causes, most common first

1. A 404 or 500 page rendered by the server, not your application

The request never reached your API code at all. It hit a web server or framework default error page first — a wrong path, a route that does not exist, or an unhandled exception that the framework converted into its own HTML error page instead of a JSON one. Check the status code and, separately, check that your error handler is actually registered for the route in question and configured to return JSON rather than falling back to a default template.

2. A misspelled or stale endpoint hitting a single-page app’s fallback route

This is the case that is epidemic in front-end development specifically. Most SPA dev servers and static hosts are configured to serve index.html for any path that does not match a real file, so that client-side routing works on a hard refresh. If /api/users is mistyped as /apiusers, or an environment variable points at the wrong base URL, or the API route was renamed and a caller was not updated, the request falls through to that same catch-all and receives the app’s own HTML shell — typically with a 200 status, which is what makes it so easy to miss. Compare the exact request URL your network tab shows against the route actually registered on the server.

3. An expired session redirected to a login page

A session or token expired, and the server (or a reverse proxy sitting in front of it) answered with the sign-in page’s HTML instead of the JSON your code expected — often with a 200, because the redirect target rendered successfully even though it is the wrong content. This is common with cookie-based auth where the browser follows a 302 to /login automatically before your code ever sees a response, and with API gateways that intercept unauthenticated requests before they reach the backend. Check the final URL fetch actually landed on (res.url) — if it does not match what you requested, a redirect happened.

4. A reverse proxy, load balancer or CDN error page

A 502 Bad Gateway, 503 Service Unavailable or 504 Gateway Timeout is frequently rendered by the infrastructure in front of your application — nginx, an ALB, Cloudflare, a CDN edge node — rather than by the application itself, because the application never answered at all. These pages are pure boilerplate HTML with no relationship to your API’s usual error format, which is exactly why they parse so badly. The status code is the fast way to tell this apart from cause 1: a 502/503/504 almost always means infrastructure, not application code.

5. A corporate proxy or captive portal

On office wifi behind an inspecting proxy, or on hotel, airport or conference wifi with a captive portal, every request — including API calls made from a background script — can be intercepted and answered with an HTML interstitial: a login page for the portal, or a block page from a corporate filter. This is diagnosable but not fixable from your code: the tell is that requests that work fine on a different network fail identically here, regardless of which endpoint is called.

6. A WAF challenge or CAPTCHA interstitial

A web application firewall or bot-protection service in front of the real API can answer a request it considers suspicious with its own challenge page — a CAPTCHA, a JavaScript challenge, or an “unusual traffic” notice — instead of passing the request through. This tends to appear under automated or high-frequency traffic: scripts, tests, and scrapers trip it far more than a human clicking through a browser.

7. The wrong base URL or environment

A request built against the marketing site’s domain instead of the API subdomain, or against a staging URL that now redirects to a parked page, returns that domain’s ordinary HTML home page — a valid 200, a valid response, and not remotely JSON. This is common after an environment variable is left pointing at a decommissioned host, or copied from a colleague’s .env file that targets a different stage.

8. CORS — usually not this error, worth ruling out explicitly

A genuine CORS rejection almost never reaches JSON.parse: the browser blocks the response before your code can read it, and fetch’s promise rejects with a network-level TypeError (“Failed to fetch” in Chrome, “NetworkError when attempting to fetch resource” in Firefox), not a SyntaxError from a parser that never got to run. If you are seeing this exact JSON error, CORS is very unlikely to be the cause — look instead at whatever a misconfigured CORS proxy or gateway substituted in its place, which is a variant of causes 3 and 4 above.

Worked example

A dashboard calls /api/account on page load. In production it fails intermittently with exactly this error. Logging the raw body reveals the true shape of the problem:

const res  = await fetch('/api/account');
const text = await res.text();
console.log(res.status, res.url, res.headers.get('content-type'));
// 200 https://app.example.com/login text/html
console.log(text.slice(0, 60));
// Sign in...

Status 200, but the URL that actually answered is /login, not /api/account — a session cookie had expired and the gateway redirected before the API ever saw the request. The fix is not in the JSON parsing at all: it is detecting a 401 (or the redirect) upstream and routing the user to a fresh sign-in, rather than letting a stale session limp forward into a parse error three layers away from the real cause.

const res = await fetch('/api/account');
if (res.redirected || res.url.includes('/login')) {
  window.location.href = '/login?next=' + encodeURIComponent(location.pathname);
  return;
}
if (!res.ok) throw new Error(`Account fetch failed: ${res.status}`);
const data = await res.json();

What to check, in order

  1. The exact request URL in your network tab — does it match the route you meant to call, character for character?
  2. res.status — 2xx, 4xx or 5xx, and which one specifically.
  3. res.url after any redirects — did you end up somewhere other than where you asked to go?
  4. Content-Type on the response — application/json or something else?
  5. The first 100–200 characters of the raw body, logged before any parsing.
  6. Whether the same request succeeds from a different network (rules in or out a proxy or captive portal) and from an authenticated session in a normal browser tab (rules in or out an auth redirect).

Fixes, concretely

The durable fix is the same regardless of which specific cause produced the HTML: stop trusting that a response is JSON just because you asked for JSON, and fail with a useful message the first time it is not.

async function getJSON(url, options) {
  const res = await fetch(url, options);
  const ctype = res.headers.get('content-type') || '';
  if (!res.ok || !ctype.includes('application/json')) {
    const body = await res.text();
    throw new Error(
      `Expected JSON from ${url}, got ${res.status} ${ctype || '(no content-type)'}: ` +
      body.slice(0, 200)
    );
  }
  return res.json();
}

That error message, the moment it is thrown, already contains the status code, the content type and a sample of the body — everything the three-step confirmation above would otherwise require you to add by hand, every single time.

Limitations and edge cases

  • Some APIs correctly return a JSON error body on a 4xx or 5xx. That is a different, well-formed case: the content type will read application/json and the body will parse; there is no bug here beyond handling the error payload your API defines. This page is specifically about an HTML body, not a JSON error response.
  • XML looks similar but is not the same fault. A legacy SOAP or XML endpoint returns <?xml version="1.0"?>…, which trips the exact same position-0 error for the exact same reason — it starts with < — but the fix is choosing the right client for that API, not chasing a 404.
  • A leading space, BOM or newline shifts the position off zero. If your error reports position 1 or 2 rather than 0 but still names <, the same diagnosis applies; something invisible just preceded the markup. Paste the body above and the tool will show you what that character actually is.
  • An image, font or other binary error response served with the wrong content type will fail differently — usually not with a token error at all, since binary data rarely starts with a printable <. If the body looks like garbage rather than markup, you are looking at a different problem: a binary payload served where text was expected.
  • Retried requests can return HTML on the retry only. If a request succeeds sometimes and fails this way intermittently, suspect a flaky proxy, a load balancer occasionally routing to an unhealthy node, or a rate limiter that returns its own HTML page above a threshold — log the status and body on every attempt, not just the first.

Frequently asked questions

Why position 0 specifically, and not somewhere in the middle?

Because JSON permits no legal way to begin a document with <. The parser fails on the very first character it reads, before it can make any further progress, which is why this variant always reports position 0 (barring an invisible character before it — see the edge cases above).

My status code is 200. How can this still be an error page?

Very easily — a login page, an SPA fallback shell, or a marketing homepage all render successfully and legitimately return 200. The status tells you the transport succeeded, not that the content is what you expected. Check res.url for a redirect and the content type for confirmation.

Does this mean my server is broken?

Not necessarily. It commonly means the request never reached your server-side code at all — it was intercepted by a proxy, a CDN, a captive portal, or a client-side routing fallback before your API logic ran. Confirming the request URL and the responding URL, per the checklist above, tells you which side of that line the problem is on.

Why does this happen constantly in local development but rarely in production?

Dev servers for single-page apps are usually configured to serve index.html for any unmatched path, which is exactly right for client-side routing and exactly wrong for a mistyped API call — the request that should 404 gets an HTML page instead. Production API servers more often reply with a proper 404 and no SPA fallback in front of the API path, so the same typo surfaces as a clean status code rather than this parse error.

Can I make fetch throw automatically on a bad status?

Not by itself — fetch deliberately treats any completed HTTP exchange as a resolved promise. You have to check res.ok (or the status code) yourself and throw, as in the getJSON helper above. Libraries built on top of fetch, such as Axios, do this checking for you and throw on a non-2xx status by default.

Is a captive portal really likely, or is that a stretch?

It is more common than it sounds for anything running on conference, hotel or airport wifi, including background scripts and native apps making API calls without a browser tab open to notice the portal. The signature is that every endpoint fails identically and the body is always the same portal HTML regardless of what was requested.

Should I just wrap JSON.parse in a try/catch and move on?

A try/catch stops the crash but throws away the most useful information you have — the status code and the body that arrived. Catch it, but log or rethrow with that context attached, as in the fix above; a silently swallowed parse error just relocates the mystery to whoever debugs the empty result later.

Related pages

Related tools