Ch. 1 · JavaScript

SyntaxError: Unexpected Token '<' in JSON: Debugging fetch

Fix SyntaxError: Unexpected token '<' in JSON from fetch: the server sent HTML. Check res.ok and content-type, then read the body as text.

~7 min readbeginnerupdated Oct 4, 2026

You called res.json() on a fetch response and got this in current Chrome, Edge or Node.js:

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Text

The JSON parser hit < as the very first character. Valid JSON never starts with <, but HTML always does. Your request reached something that answered with an HTML page instead of the JSON you expected, and res.json() tried to parse it anyway. The useful question is not “what is wrong with my JSON?” but “which page did the server send me, and why?”

Older Chrome and Node versions phrased it as Unexpected token < in JSON at position 0. Firefox says JSON.parse: unexpected character at line 1 column 1 of the JSON data, and Safari says JSON Parse error: Unrecognized token '<'. A close cousin, Unexpected end of JSON input, means the body was empty.

Quick fix checklist

  • Open DevTools, Network panel, click the request and look at the Response tab: you will see the HTML.
  • Check the status code. 404 means wrong URL, 500 means a server error page, 401/302 often means a login page.
  • Check the full request URL, especially relative paths like api/orders without a leading slash.
  • In code, check res.ok and the content-type header before calling res.json().
  • While debugging, replace await res.json() with await res.text() and log the first few hundred characters.
  • For Unexpected end of JSON input, look for a 204 No Content or an empty 200 response.

Before you start

You should know the basic fetch flow (await fetch(url) gives a Response, and await res.json() reads and parses its body) and how to open the Network panel in your browser’s DevTools. The Node examples need Node.js 18 or newer, which has fetch built in; the exact error wording shown is from Node 22.

Why it happens

fetch only rejects when it cannot get a response at all (a network failure, DNS error or CORS block). A 404 or 500 is still a successful response from fetch’s point of view. res.json() then does exactly one thing: reads the body and runs JSON.parse on it. It does not look at the status code or the Content-Type header. So any HTML page the server returns goes straight into the parser:

for (const text of ['<!DOCTYPE html><html>', '', 'Internal Server Error', "{'a':1}"]) {
  try { JSON.parse(text); } catch (e) { console.log(JSON.stringify(text), '->', String(e)); }
}
// "<!DOCTYPE html><html>" -> SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
// "" -> SyntaxError: Unexpected end of JSON input
// "Internal Server Error" -> SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON
// "{'a':1}" -> SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
JavaScript

The character in the message identifies what came back. < is HTML. I is a plain-text “Internal Server Error”. A body of literally undefined gets its own message, "undefined" is not valid JSON, which means something serialised a missing value. Single-quoted keys mean someone hand-built the JSON.

The usual sources of the HTML page:

  1. Wrong URL. A typo, a missing path segment or a relative URL resolving somewhere unexpected returns a 404 page.
  2. SPA fallback. Hosts and dev servers configured for single-page apps answer every unknown path with index.html and status 200, so even a wrong API URL looks successful.
  3. Server error page. The API crashed and the framework rendered an HTML 500 page.
  4. Authentication redirect. An expired session redirects to /login, fetch follows the redirect, and you receive the login page.
  5. Proxy or gateway pages. A load balancer’s 502 page or a corporate proxy’s block page.

Step-by-step walkthrough

Step 1: Reproduce it against a controlled server

This Node server mimics a typical setup: one JSON route, one empty response, and an SPA fallback for everything else.

import http from 'node:http';

export const server = http.createServer((req, res) => {
  if (req.url === '/api/orders') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify([{ id: 1, total: 4200 }]));
  }
  if (req.url === '/api/orders/clear') {
    res.writeHead(204);
    return res.end();
  }
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end('<!DOCTYPE html><html><body><div id="app"></div></body></html>');
});
JavaScript

Calling res.json() on each route (saved as server.mjs next to this script):

import { server } from './server.mjs';

await new Promise((resolve) => server.listen(0, resolve));
const base = `http://localhost:${server.address().port}`;

for (const path of ['/api/orders', '/api/order', '/api/orders/clear']) {
  try {
    const res = await fetch(base + path);
    console.log(path, await res.json());
  } catch (e) {
    console.log(path, String(e));
  }
}
server.close();
// /api/orders [ { id: 1, total: 4200 } ]
// /api/order SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
// /api/orders/clear SyntaxError: Unexpected end of JSON input
JavaScript

A one-letter typo (/api/order) returned 200 OK with HTML. Nothing in the status warned you.

Step 2: Look at what actually came back

In the browser, the Network panel’s Response tab shows the body. In code, swap res.json() for res.text() temporarily and log res.status, res.url (the final URL after redirects), res.headers.get('content-type') and the first part of the text. A <title> like “404 Not Found”, “Sign in” or your own app’s name answers the question within seconds.

Step 3: Fix the cause, not the parser

Correct the URL, fix the server error, refresh the session, or exclude /api routes from the SPA fallback so unknown API paths return a real 404. If the server returns JSON with the wrong Content-Type (for example text/html on a JSON body), fix the header on the server.

Step 4: Make the client fail with a useful message

Wrap the parsing in a helper that checks status and type before parsing, and includes a snippet of the body in its error:

export async function readJson(res) {
  const type = res.headers.get('content-type') ?? '';
  if (res.status === 204) return null; // no body by definition
  const text = await res.text();        // read once, as text, so we can report it
  if (!res.ok) {
    throw new Error(`HTTP ${res.status} from ${res.url}: ${text.slice(0, 120)}`);
  }
  if (!type.includes('json')) {
    throw new Error(`Expected JSON from ${res.url} but got ${type || 'no content-type'}: ${text.slice(0, 60)}`);
  }
  return text === '' ? null : JSON.parse(text);
}
JavaScript

A body can only be read once, so the helper reads it as text and parses that string instead of calling both res.text() and res.json(). Checking for json anywhere in the type also accepts application/problem+json and similar types.

Worked scenario

An admin dashboard works on localhost and breaks after deployment under https://shop.example.com/dashboard/. The code fetches orders with a relative URL:

// Browser code (illustrative)
const res = await fetch('api/orders');
const orders = await res.json(); // SyntaxError: Unexpected token '<' ...
JavaScript

Diagnosis. The Network panel shows a request to /dashboard/api/orders with status 200 and an HTML body: the app’s own index.html. A URL without a leading slash is resolved against the current page’s path, not the site root:

console.log(new URL('api/orders', 'https://shop.example.com/').href);
console.log(new URL('api/orders', 'https://shop.example.com/dashboard/settings').href);
console.log(new URL('/api/orders', 'https://shop.example.com/dashboard/settings').href);
// https://shop.example.com/api/orders
// https://shop.example.com/dashboard/api/orders
// https://shop.example.com/api/orders
JavaScript

Locally the app lived at the root, so the two resolved identically. In production the path pointed into the dashboard’s own routes, and the host’s SPA fallback served index.html with a 200.

Fix. Use an absolute path or a configured API base URL, and parse through the checking helper:

// Browser code (illustrative)
const API_BASE = '/api'; // or import.meta.env.VITE_API_URL in a Vite app
const orders = await readJson(await fetch(`${API_BASE}/orders`));
JavaScript

Also configure the host so /api/* never falls back to index.html; a wrong API path should be a loud 404, not a quiet 200.

Common mistake

The tempting fix is wrapping res.json() in try/catch and returning [] on failure. The page stops crashing and shows “No orders”, which users read as truth. Meanwhile the real problem (expired sessions, a broken route, a crashing endpoint) is invisible in your logs.

Another mistake is trying to “clean” the text before parsing, such as stripping everything before the first {. The body is an entirely different document; no amount of trimming turns a login page into your data.

Finally, res.ok alone is not enough. As the reproduction showed, SPA fallbacks and proxies can return HTML with status 200, so check the content type too.

Verify the behavior

Run the helper against all three routes of the test server from Step 1:

import assert from 'node:assert/strict';
import { server } from './server.mjs';
import { readJson } from './read-json.mjs';

await new Promise((resolve) => server.listen(0, resolve));
const base = `http://localhost:${server.address().port}`;

assert.deepEqual(await readJson(await fetch(`${base}/api/orders`)), [{ id: 1, total: 4200 }]);
assert.equal(await readJson(await fetch(`${base}/api/orders/clear`)), null);
await assert.rejects(readJson(await fetch(`${base}/api/order`)), /Expected JSON .* but got text\/html/);

server.close();
console.log('readJson handles JSON, empty and HTML responses');
// readJson handles JSON, empty and HTML responses
JavaScript

The HTML case now fails with Expected JSON from http://.../api/order but got text/html; charset=utf-8: <!DOCTYPE html>..., which points straight at the cause. In the browser, reload with the Network panel open and confirm the API request shows application/json in its response headers.

Interview exercise

“fetch did not throw, res.status is 200, yet res.json() throws Unexpected token '<'. Walk me through what is happening and how you would design the client to handle it.”

Answer and reasoning

fetch resolves for any HTTP response, including errors, and res.json() only parses the body without checking metadata, so a 200 with an HTML body passes every step until the parser. The leading < tells me the body is HTML. With status 200, the most likely sources are an SPA fallback serving index.html for an unknown path, a redirect that fetch followed to a login page (I would check res.redirected and res.url), or a proxy page. I would confirm by logging res.url, the content-type header and the start of res.text(). For the design, I would centralise requests in one helper that checks res.ok and the content type, reads the body once as text, and throws an error containing status, URL and a body snippet. Callers can then distinguish “server said no” from “server sent garbage”, and errors in monitoring become self-explanatory. On the server side, I would make API routes return JSON errors with correct status codes and keep them out of the SPA fallback.

Continue learning

Practise more with the JavaScript interview questions and the JavaScript MCQs. To give the helper’s errors a proper type, read custom error classes; if the request never got a response at all, see blocked by CORS policy; and if the thrown error escapes as Uncaught (in promise), see unhandled promise rejections. References: MDN on Response.json(), Response.ok and JSON.parse errors.

More in JavaScript

esc