DEV Community

코딩나우(하늘아래)
코딩나우(하늘아래)

Posted on Originally published at coding-now.com

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
Enter fullscreen mode Exit fullscreen mode

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); }
}
Enter fullscreen mode Exit fullscreen mode
20 -> Unexpected token '<', "<xxxxxxxxxxxxxxxxxxx" is not valid JSON
21 -> Unexpected token '<', "<xxxxxxxxx"... is not valid JSON
Enter fullscreen mode Exit fullscreen mode

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); }
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8

<!DOCTYPE html><html lang="ko">    <- 26,283 bytes
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)