That JSON error quotes what the server actually sent - and V8 cuts it at exactly 20 characters
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Everyone knows what this means by now: the server sent HTML, your `res.json()` tried to parse it, the parser quit on the first character.
What I had never thought about is the quoted part. `"<!DOCTYPE "` is not a canned string V8 prints for HTML responses. It is **the literal first ten characters of whatever you handed the parser**. Which makes it a free, zero-effort peek at the response body — and it is more precise than I assumed.
## Where the cut happens
I grew the input one character at a time on node v24.13.0 (V8 13.6.233.17):
for (let n = 8; n <= 24; n++) {
const s = '<' + 'x'.repeat(n - 1);
try { JSON.parse(s); } catch (e) { console.log(n, e.message); }
}
20 -> Unexpected token '<', "<xxxxxxxxxxxxxxxxxxx" is not valid JSON
21 -> Unexpected token '<', "<xxxxxxxxx"... is not valid JSON
**20 characters or fewer: quoted whole. 21 or more: first ten, then`...`.** One threshold, one prefix width.
Except the ten is not measured from the start of the string. It is measured from **where parsing failed**. Push the failure point inward with a value that starts out valid, and the quote grows to match:
for (const pad of [0, 1, 2, 5, 10]) {
const s = '[' + ' '.repeat(pad) + 'o'.repeat(40); // '[' opens an array, then junk
try { JSON.parse(s); } catch (e) { console.log(e.message); }
}
fails at 1 -> "[oooooooooo"... 11 chars
fails at 2 -> "[ oooooooooo"... 12
fails at 3 -> "[ oooooooooo"... 13
fails at 6 -> "[ oooooooooo"... 16
fails at 11 -> " oooooooooo"... 20 <- and now it slides
So the actual rule: **the quoted window ends ten characters past the failure point and is capped at 20 characters wide** , sliding forward once it hits the cap — and if the whole input fits in 20, you get all of it with no `...`.
For an HTML response none of that matters, because `<` fails at index 0 and the window is just the first ten. But it explains why the same error sometimes quotes eleven characters, or a run of text from the middle of the payload.
The message still gives you two free readings:
* A trailing `...` means the body was longer than 20 characters.
* **No** `...` means the quotes contain the entire response. Whatever is between them is everything the server sent.
The second one is the useful half. A short body is fully diagnosed from the error line alone, without opening DevTools.
## The prefix is a fingerprint
Ten characters is enough to identify most things that show up where JSON should be. Each row below is the message that body actually produced:
Quoted prefix | Body started as | Typically
---|---|---
`"<!DOCTYPE "...` | an HTML document | 404/500 page, login page, SPA `index.html`
`"<script sr"...` | HTML starting with `<script src=` | a script injected ahead of the page, a CDN or WAF interstitial
`"<?xml vers"...` | XML | `<Error><Code>` from object storage
`"Internal S"...` | one line of plain text | a proxy or gateway wrote the 500 itself
`"undefined"` | the string `"undefined"` | not the server — you passed a missing value
`"[object Object]"` | a stringified object | `JSON.parse` on something already parsed
The last two are worth internalising, because they print **without** any `Unexpected token` prefix — just `"undefined" is not valid JSON`. There is no token to name, so V8 skips that clause entirely. If you see that shape, stop looking at the network tab; nothing came off the wire.
## Meanwhile, nobody honours your Accept header
I assumed a framework would at least try to answer JSON when you ask for it explicitly. It does not. Against this site's own Next.js 16 dev server:
curl -s -i -H "Accept: application/json" http://localhost:3000/api/nope
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
<!DOCTYPE html><html lang="ko"> <- 26,283 bytes
26,283 bytes of rendered 404 page, in reply to a request that said it wanted JSON. Production, on Vercel, sent 20,672 bytes of the same thing. The `Accept` header changed nothing on either.
## And `fetch` hands it to you as a success
Same call against five targets:
Target | status | ok | redirected | Content-Type | `res.json()`
---|---|---|---|---|---
missing API route, dev | 404 | false | false | text/html | SyntaxError
missing API route, prod | 404 | false | false | text/html | SyntaxError
http -> https redirect | 404 | false | **true** | text/html | SyntaxError
healthy JSON API | 200 | true | false | application/json | parsed
port with nothing on it | — | — | — | — | TypeError
In the first four rows `fetch` never threw. Every exception came from `res.json()`. So the message points at JSON while the actual evidence — `status`, `Content-Type`, `redirected` — was already sitting in your hand one line earlier.
Two of those rows deserve a note:
**`redirected: true`** is the login-page case. `fetch` follows redirects silently, so an expired session gets you a 200 with a login page in the body and no indication that you went somewhere else. `res.url` is the tell.
**The last row is a different error entirely.** No `res` exists. Node says `TypeError: fetch failed`, Chrome 152 says `TypeError: Failed to fetch`. Same situation, two strings, and only one of them is searchable alongside the other.
## The wrapper
The fix is to look at the body before the parser does, and to put the body in the error when it is not JSON:
async function getJson(url) {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
const type = res.headers.get('content-type') || '';
const body = await res.text(); // text first, not json
if (!type.includes('application/json')) {
throw new Error(
`Not JSON: ${res.status} ${type}\n` +
`final URL: ${res.url}${res.redirected ? ' (redirected)' : ''}\n` +
`first 200 chars: ${body.slice(0, 200)}`
);
}
if (!res.ok) throw new Error(`${res.status}: ${body.slice(0, 200)}`);
return JSON.parse(body);
}
`res.text()` first is the part that matters. The body is a stream you get **once** , so calling `res.text()` after `res.json()` already failed throws a second, less interesting error about the body being disturbed. Reverse the order and the body is still yours at the moment you need it.
The `Content-Type` check also catches the case an `res.ok` guard cannot: a dev server or proxy answering `/api/...` with `index.html` at status **200**.
## Python tells you nothing
Worth knowing if you move between the two. Four bodies that fail for four different reasons, through `json.loads` on python 3.11.9:
'<!DOCTYPE html>\n<html>' -> Expecting value: line 1 column 1 (char 0)
'' -> Expecting value: line 1 column 1 (char 0)
'Internal Server Error' -> Expecting value: line 1 column 1 (char 0)
'undefined' -> Expecting value: line 1 column 1 (char 0)
Identical. `char 0` says the first character was not the start of a value and stops there — it never says which character. The V8 message is doing real work that `json` does not, so on the Python side you have to print `r.text[:200]` yourself.
One more asymmetry in the same direction: `urllib` **raises** `HTTPError` on a 404, where `fetch` resolves happily. `requests` behaves like `fetch` and leaves `r.ok` to you.
The long version, with the five causes and how the headers separate them, is here: https://www.coding-now.com/en/guides/unexpected-token-doctype-json?utm_source=devto
If anyone knows whether that 20/10 window is stable across V8 versions, I would like to hear it — I only measured one, and I did not check SpiderMonkey or JavaScriptCore at all. And if you have seen a prefix that is not in the table above, post it; the fingerprint list is the part that gets more useful with more eyes.