Unexpected Token in JSON at Position N — Fix
Unexpected token in JSON means JSON.parse got non-JSON text.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓Basic fetch and JSON.parse use
- ✓Browser console or Node REPL comfort
- ✓Python 3 for json.tool linting
- Unexpected token in JSON means JSON.parse received text that is not valid JSON, and the position names the exact offending character.
- A < at position 0 means you fetched an HTML error page instead of JSON. Log the raw text before parsing to see it.
- The usual culprits are trailing commas, single quotes, comments, BOM bytes, and empty bodies on 204 or error responses.
- Guard every parse: check res.ok and the content-type header, read text first, and only parse when the body is non-empty.
Think of JSON.parse as a strict grammar teacher grading a quiz. One misplaced comma, one quote facing the wrong way, or one blank page handed in, and she marks the whole paper wrong while pointing at the exact word. The position number is her red circle around the mistake. Your job is not to argue with the grading. It is to look at what was actually handed in, because half the time the server submitted an HTML apology letter instead of the quiz.
Your fetch call returns, response.json() blows up, and the console reports Unexpected token < in JSON at position 0. Or a config file that looks perfect throws at position 87. The data was right there a moment ago, and now parsing acts like it received garbage. In a real sense, it did.
This error never means the parser is broken. JSON.parse is one of the most battle-tested functions in the language, and its position pointer is honest: the character at that offset is where valid JSON stopped. The text you handed it differs from what you assume, and the gap between assumption and bytes is the entire bug.
This guide closes that gap systematically. You will decode what the position number points at, recognize the HTML-page signature instantly, clear out trailing commas and single quotes, strip invisible BOM bytes, handle empty bodies on 204s, and build a fetch wrapper that validates status and content-type before parsing. Every fix starts with looking at the raw text, and this guide makes that habit automatic.
Reading the Error: What Position N Actually Points At
The message has three parts and each carries information. Unexpected token names the character that broke the grammar, such as <, ,, ', or end of input for empty strings. in JSON at position N gives the zero-based offset where parsing stopped, meaning every character before N was legal. Together they form an address: go to offset N and stare at that byte.
Newer V8 versions add a helpful line showing the text around the failure, while older ones give only the token and offset. Either way, your move is identical: print the raw input with JSON.stringify so invisible characters show as escapes, then slice around N. console.log(text.slice(Math.max(0, N - 40), N + 40)) frames the crime scene with forty characters of context on each side.
Positions mislead in one common way. A missing closing brace early in the document can cascade, with the parser failing far downstream where the structure finally contradicts itself. When offset N looks innocent, widen the view: validate the whole document with a linter instead of trusting one address. The pointer is honest about where it gave up, not always about where you erred.
The snippet below demonstrates the anatomy deliberately. It parses three broken inputs, catches each SyntaxError, and prints the token context around the reported offset. Run it once and the message format stops feeling cryptic forever.
The < Giveaway: You Fetched an HTML Error Page
A less-than sign at position 0 is the most common variant of this error, and it always means the same thing: the body is HTML. JSON never legally starts with <, while every HTML document starts with <html or <!doctype. Your code asked for data and received a web page, usually a 404 page, a login wall, a proxy error, or a dev server's fallback index.html for unknown routes.
Single-page-app dev servers are the friendliest source. They serve index.html for any unrecognized path so client routing works, which means a typo'd /api/users endpoint returns your own app shell with a 200 status. Your parser sees < and throws at zero. Backend proxies do the same with login walls, and CDNs serve HTML error pages on cache misses with statuses your code ignores.
The fix has two halves. First, repair the immediate cause: correct the URL, restore the session, or fix the server route so the endpoint emits JSON. Second, stop parsing blindly: check res.ok and require the content-type to include application/json before calling .json(). An HTML body then produces your own clear error naming the URL and status instead of a cryptic token complaint.
The snippet is the guard every frontend needs. It reads text once, validates status and type, rejects empties, and only then parses. Adopt it as the single path from fetch to data and the < variant disappears as a class.
Trailing Commas, Single Quotes, and Other Almost-JSON
JavaScript object literals accept conveniences that JSON forbids, and muscle memory smuggles them into .json files and payload builders. Trailing commas after the last property or element top the list: {"a": 1,} parses as a literal but throws in JSON.parse. Single-quoted strings come next, followed by comments, unquoted keys, and special values like undefined and NaN. Each is legal JS and illegal JSON.
Hand-built JSON strings are the riskiest source. Template literals that interpolate values into '{"name": "' + name + '"}' break the moment a value contains a quote or newline. Manual construction also invites trailing commas when the last interpolated field is conditional. Building the value as a real object and serializing once with JSON.stringify removes the entire category, since the serializer only emits legal JSON.
Config files fail differently: they look right because editors do not validate JSON by default. A tsconfig, package.json, or eslintrc with one trailing comma throws at startup with a position deep in the file. Running python3 -m json.tool on the file reports line and column instantly, and most editors gain squiggles from a JSON language mode or schema plugin.
The snippet shows each almost-JSON form failing, then the correct construction pattern. When you control the producer, serialize objects instead of concatenating strings. When you only control the consumer, validate and reject with the offending slice logged.
BOM and Invisible Characters Hiding at Position 0
Sometimes the text looks flawless and position 0 still fails. The culprit is usually invisible: a byte-order mark, zero-width space, or stray control character at the start of the input. Files saved as UTF-8 with BOM begin with U+FEFF, three bytes that render as nothing in most editors but sit squarely at offset 0 where JSON demands a value character. Copy-pasted payloads from chat tools and docs smuggle zero-width joiners the same way.
Diagnose with char codes instead of eyes. text.charCodeAt(0) returning 65279 confirms a BOM. Logging JSON.stringify(text.slice(0, 10)) reveals \ufeff escapes and \u200b zero-width spaces that plain console.log swallows. The eyes cannot debug what they cannot render, so make the bytes visible before theorizing.
Strip defensively at ingestion boundaries: text.replace(/^\uFEFF/, "") removes a leading BOM in one pass. For zero-width characters interleaved in pasted content, a broader replace of [\u200B-\u200D\uFEFF] clears them. Apply this where external text enters your system, file readers and webhook handlers, rather than at every parse call.
Prevention is an editor setting. Save JSON as UTF-8 without BOM, enable visible-whitespace rendering when hunting these, and add a CI check that fails files starting with a BOM. The snippet demonstrates detection and stripping on a BOM-prefixed payload.
Empty Bodies and 204s: Parsing Nothing
An empty string is never valid JSON, so any 204 No Content, empty 200, or consumed-stream response throws Unexpected token with an end-of-input message. REST APIs return 204 for successful DELETEs and PUTs with nothing to say. Error paths sometimes emit zero bytes. Either way, calling .json() unconditionally converts a healthy empty into a crash.
A sneakier variant is the double-read. Response bodies are single-use streams: awaiting res.text() for logging and then res.json() leaves the second call staring at an exhausted stream. In some runtimes that second read throws about disturbed bodies rather than tokens, but in others it surfaces as an empty parse failure. Read once into text and work from that copy for both logging and parsing.
The guard is trivial and belongs before the parse, not in a catch. After reading text, if (!text.trim()) return null or a documented default for endpoints known to be empty. For endpoints that should always return JSON, treat emptiness as the server bug it is and throw an error naming the URL and status.
The snippet models both halves: endpoints that legitimately return nothing and endpoints where emptiness signals trouble. One branch returns a default, the other raises a clear diagnostic, and neither lets an empty string reach the parser.
Validate Before You Ship: Linting Payloads in Dev and CI
Catching malformed JSON in CI beats catching it in production by every measure that matters. A one-line lint over config files, python3 -m json.tool file.json, fails builds on trailing commas before they crash boots. For API contracts, snapshot a real response body per endpoint and assert it parses plus matches the expected shape on every run. Shape drift then breaks the build while the context is fresh.
In the browser console, keep a two-line validation habit. Fetch as text, then parse in a visible try/catch that prints the slice around the failure. This beats chaining .json() directly because you see the bytes that failed instead of a bare position. Paste suspect payloads into the console with JSON.parse and read the reported offset against the stringified form.
Contract tests deserve the investment proportional to blast radius. A checkout payload parsed by six consumers justifies a schema test with fixtures. An internal admin endpoint used twice a month justifies the json.tool lint and nothing more. Match the armor to the value of what breaks.
Close the loop with alerting on parse failures in production. A sudden spike of token errors at position 0 means a proxy or deploy started serving HTML, and the first-120-characters log you added earlier names the page instantly. Positions become dashboards instead of mysteries.
A Login Page in JSON Clothing Broke Search for 34 Minutes
res.json() unconditionally, so the first < of <html> threw at position 0 on all 11,000 search requests during the window. No check on content-type or status existed between fetch and parse.- Status 200 proves nothing about body shape, so monitors and clients must assert content-type and parseability, not just the code.
- Proxies that serve HTML on API paths turn every auth hiccup into a parsing outage. API routes should only ever emit the documented content type.
- Logging the first bytes of a failed body turns a 30-minute mystery into a 30-second diagnosis. Raw text first, always.
res.text(); console.log(JSON.stringify(text.slice(0, 200))). Compare character N against the reported token. The surrounding bytes name the culprit: <html means an HTML page, a comma before } means trailing comma, nothing at all means an empty body.| File | Command / Code | Purpose |
|---|---|---|
| parse-anatomy.js | function inspect(input) { | Reading the Error |
| safe-json.js | async function safeJson(res) { | The < Giveaway |
| almost-json.js | const bad = ['{"a":1,}', "{'a':1}", '{"a": undefined}']; | Trailing Commas, Single Quotes, and Other Almost-JSON |
| bom-strip.js | const polluted = '\uFEFF{"a":1}'; | BOM and Invisible Characters Hiding at Position 0 |
| empty-body.js | function parseBody(text, url, allowEmpty) { | Empty Bodies and 204s |
Key takeaways
Common mistakes to avoid
5 patternsChaining res.json() without reading the raw text
res.text() first, log the first 200 characters, then parse the same copy.Trusting HTTP 200 to mean JSON
Building JSON with string concatenation
Parsing without an empty-body guard
Reading the response stream twice
res.text() then parsing with res.json() fails on the exhausted stream.Interview Questions on This Topic
What does Unexpected token < in JSON at position 0 mean?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's Basics. Mark it forged?
5 min read · try the examples if you haven't