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 JSONThe 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/orderswithout a leading slash. - In code, check
res.okand thecontent-typeheader before callingres.json(). - While debugging, replace
await res.json()withawait res.text()and log the first few hundred characters. - For
Unexpected end of JSON input, look for a204 No Contentor an empty200response.
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)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:
- Wrong URL. A typo, a missing path segment or a relative URL resolving somewhere unexpected returns a 404 page.
- SPA fallback. Hosts and dev servers configured for single-page apps answer every unknown path with
index.htmland status 200, so even a wrong API URL looks successful. - Server error page. The API crashed and the framework rendered an HTML 500 page.
- Authentication redirect. An expired session redirects to
/login,fetchfollows the redirect, and you receive the login page. - 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>');
});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 inputA 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);
}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 '<' ...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/ordersLocally 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`));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 responsesThe 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.