Unexpected end of JSON input (tested)
SyntaxError: Unexpected end of JSON input means the parser ran out of text. Usually the body was empty. Unexpected token '<' means the body was HTML, not JSON. We reproduced each cause in Chromium, Node.js and Python and recorded the exact messages.
1. The short answer
res.json() and JSON.parse() do not care about the status code. They just parse whatever text arrived. Two cases cover most reports:
- Empty body (204, HEAD, a 200 with nothing in it, whitespace only):
Unexpected end of JSON input. - HTML body (an error page, a 404 page, your SPA's
index.html):Unexpected token '<', "<!DOCTYPE "... is not valid JSON.
The fix is the same for both: read the body as text first, check res.ok and the Content-Type, and only then parse.
2. Our test setup
A Node.js server returned one bad response per URL: 204 with no body, 200 application/json with no body, a cut-off JSON body, a 500 HTML error page, whitespace only, and so on. We called each URL with fetch(url).then(r => r.json()) in Chromium 153 and in Node.js 24, and with Python requests and urllib. We also ran JSON.parse on sample strings in both engines.
3. Results: cause → exact message
| Cause | Chromium 153 res.json() | Node 24 res.json() |
|---|---|---|
| 204 No Content | SyntaxError: Failed to execute 'json' on 'Response': Unexpected end of JSON input | SyntaxError: Unexpected end of JSON input |
| 200 with empty body | SyntaxError: Failed to execute 'json' on 'Response': Unexpected end of JSON input | SyntaxError: Unexpected end of JSON input |
| HEAD request (server sent a JSON Content-Type) | SyntaxError: Failed to execute 'json' on 'Response': Unexpected end of JSON input | SyntaxError: Unexpected end of JSON input |
| Body is only whitespace | SyntaxError: Unexpected end of JSON input | SyntaxError: Unexpected end of JSON input |
Body truncated: {"items":[1,2,3 | SyntaxError: Expected ',' or ']' after array element in JSON at position 15 (line 1 column 16) | Same |
500 HTML page starting <!DOCTYPE html> | SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON | Same |
404 HTML page starting <html> | SyntaxError: Unexpected token '<', "<html><bod"... is not valid JSON | Same |
| Connection dropped before Content-Length was reached | TypeError: Failed to fetch | TypeError: terminated |
Body is the text undefined | SyntaxError: "undefined" is not valid JSON | Same |
Opaque response (cross-origin, mode: 'no-cors') | SyntaxError: Unexpected end of input | n/a (Node returned the real body) |
Calling res.json() twice | TypeError: Failed to execute 'json' on 'Response': body stream already read | TypeError: Body is unusable: Body has already been read |
Notes from the run:
- A 204 does not make
res.json()returnnull. It throws. A body of literallynulldoes parse, tonull. - A cut-off body only gives "Unexpected end of JSON input" if it stops at the right spot. Our
{"items":[1,2,3gave the "Expected ',' or ']'" message instead.JSON.parse('{"a":')did give "Unexpected end of JSON input". - The opaque case is the odd one. A no-cors cross-origin response has
type"opaque",status0 and an empty body, even though our server sent{"a":1}. The message has no "JSON" in it. No-cors is not a way around CORS; fix the headers instead (see CORS with multiple origins). - A UTF-8 BOM before
{"a":1}parsed fine in bothres.json()implementations.
4. JSON.parse messages (same in both engines)
| Input | Message |
|---|---|
"" or " " | Unexpected end of JSON input |
'{"a":' | Unexpected end of JSON input |
"<!DOCTYPE html>" | Unexpected token '<', "<!DOCTYPE html>" is not valid JSON |
"OK" | Unexpected token 'O', "OK" is not valid JSON |
JSON.parse(undefined) | "undefined" is not valid JSON |
"[object Object]" (an object passed through String()) | "[object Object]" is not valid JSON |
"{'a':1}" (single quotes) | Expected property name or '}' in JSON at position 1 (line 1 column 2) |
'{"a":1}x' | Unexpected non-whitespace character after JSON at position 7 (line 1 column 8) |
Short inputs are quoted whole. Longer ones are cut to 10 characters plus ..., which is why you see "<!DOCTYPE "....
5. Python: requests and urllib
Python has a different message for all of these. Empty body, 204, HEAD and an HTML page all gave the same text:
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
The truncated body gave Expecting ',' delimiter: line 1 column 16 (char 15). requests' JSONDecodeError is a subclass of ValueError, so except ValueError catches it. Unlike fetch, calling r.json() twice worked. With urllib, a 500 raised HTTPError: HTTP Error 500: Internal Server Error before we could parse anything; a 204 or empty 200 gave json.JSONDecodeError: Expecting value: line 1 column 1 (char 0).
r = requests.get(url)
r.raise_for_status()
if r.status_code == 204 or not r.content.strip():
data = None
else:
data = r.json()
6. The fix: read text, check, then parse
async function getJSON(url, init) {
const res = await fetch(url, init);
const text = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 80)}`);
if (!text.trim()) return null; // 204, HEAD, empty body
const type = res.headers.get('content-type') || '';
if (!type.includes('application/json'))
throw new Error(`Expected JSON, got ${type}: ${text.slice(0, 80)}`);
return JSON.parse(text);
}
We ran this against our server. A 204 returned null. The 500 page threw HTTP 500: <!DOCTYPE html>.... An SPA fallback (200 text/html) threw Expected JSON, got text/html: .... Valid JSON parsed as normal. You now see the real problem instead of a parser error.
Reading the body once with res.text() also avoids the "body stream already read" error. If you need the body twice, use res.clone().
7. Where the HTML comes from
- A server error page. Your framework or proxy returns an HTML page for a 500, 502 or 504. Check
res.statusfirst. - Wrong URL. A typo or missing
/apiprefix hits the SPA catch-all route, which returnsindex.htmlwith status 200.res.okis true, so only a Content-Type check catches it. - Dev server proxy not set up. The front-end dev server answers
/api/...itself with its HTML page. - Login redirect. An expired session redirects to an HTML login page. fetch follows the redirect and you parse the login form.
On the server side, send JSON errors with the right header. See Content-Type: application/json. For empty answers, return 204 and make the client handle it, or return a JSON body such as {} with 200. A 304 Not Modified also has no body; per the Fetch spec the browser normally serves the cached body for you (not measured here).
Frequently asked questions
Why does res.json() fail on a 204 response?
A 204 has no body, and res.json() tries to parse an empty string. In Chromium 153 we got "Failed to execute 'json' on 'Response': Unexpected end of JSON input"; Node 24 said "Unexpected end of JSON input". Check res.status === 204 or read res.text() and skip empty bodies.
What does Unexpected token '<' mean?
The body starts with <, so it is HTML, not JSON. Usually it is an error page, a 404 page or your SPA's index.html. Log res.status, the Content-Type and the first 100 characters of res.text() to see which.
Does res.json() check the Content-Type header?
No. In Node 24 a valid JSON body sent as text/html parsed fine with res.json(). The parser only looks at the text. That is why you should check the Content-Type yourself before parsing.
Why does fetch with mode no-cors give Unexpected end of input?
A cross-origin no-cors response is opaque: status 0 and an empty body. Chromium 153 threw "SyntaxError: Unexpected end of input" on res.json(). You cannot read it. Add CORS headers on the server instead.
What is the Python version of this error?
requests raises requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0) for an empty body, a 204, a HEAD response or an HTML page. Call raise_for_status() and check r.content before r.json().
Written with AI assistance. Measured on 2026-10-02 with headless Chromium 153.0.8010.12 (Playwright), Node.js 24.21.0 built-in fetch, Python 3.13.5 with requests 2.32.3 and urllib, against a local Node test server. PlainHeaders is our extension; Chromium, Node.js and Python are free.