Home JavaScript Unexpected Token in JSON at Position N — Fix
Beginner 5 min · September 23, 2026

Unexpected Token in JSON at Position N — Fix

Unexpected token in JSON means JSON.parse got non-JSON text.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 10 min
  • Basic fetch and JSON.parse use
  • Browser console or Node REPL comfort
  • Python 3 for json.tool linting
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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.
✦ Definition~90s read
What is Unexpected Token JSON Parse Fix?

Unexpected token in JSON at position N is a SyntaxError thrown by JSON.parse when the input text violates the JSON grammar at character offset N. The parser reads left to right, and the moment it meets a character that cannot legally appear there, it stops and names that character plus its zero-based index.

Think of JSON.parse as a strict grammar teacher grading a quiz.

Unexpected token < in JSON at position 0 means the very first byte was a less-than sign. Unexpected token } at position 42 means 42 characters parsed cleanly and the 43rd broke the rules.

JSON is deliberately stricter than JavaScript object literals, and every relaxation developers expect is rejected. Trailing commas after the last element are illegal. Single-quoted strings are illegal. Comments of any style are illegal. Unquoted keys are illegal. undefined, NaN, and Infinity are illegal values.

A literal that runs fine as a JS object can be fatally invalid as JSON, which surprises everyone exactly once.

The input is wrong far more often than it looks. Fetch responses carry HTML error pages when endpoints 404 or the dev server serves index.html for unknown routes. Proxies inject login pages. Files saved on Windows carry a BOM prefix. APIs return 204 No Content or empty 200 bodies that are zero bytes of nothing. Each produces a different token and position, and each maps to one section below.

The debugging discipline is therefore fixed: never parse blind. Read the raw text, log its first hundred characters, check its length, and only then call JSON.parse inside a try/catch that reports the offending slice. The position number plus the raw text identifies every variant of this error within a minute.

Plain-English First

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.

parse-anatomy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
function inspect(input) {
  try {
    JSON.parse(input);
    console.log('parsed:', JSON.stringify(input));
  } catch (err) {
    console.log('input:  ', JSON.stringify(input));
    console.log('failure:', err.message);
  }
}

inspect('{"a":1,}');
inspect("{'a': 1}");
inspect('');
Try it live
📊 Production Insight
Error trackers that record only err.message without the offending body force a second deploy to learn what the server sent. Always log the first 120 characters of the failed text alongside the message, and position numbers become instantly actionable.
🎯 Key Takeaway
The token names the bad character and the position names its offset. Print the raw text with context around N before changing anything.

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.

safe-json.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
async function safeJson(res) {
  const text = await res.text();
  if (!res.ok) throw new Error('http ' + res.status + ' for ' + res.url);
  const type = res.headers.get('content-type') || '';
  if (!type.includes('application/json')) {
    throw new Error('expected JSON, got "' + type + '": ' + text.slice(0, 120));
  }
  if (!text.trim()) throw new Error('empty body for ' + res.url);
  return JSON.parse(text);
}
Try it live
⚠ Status 200 Does Not Mean JSON
Login walls, fallback pages, and proxy errors all return 200 with HTML bodies. Assert the content-type header on every response instead of trusting the status code.
📊 Production Insight
Proxies that answer API paths with 200 HTML are an outage waiting for a session expiry. Contract-test that every API route returns JSON content-type on success and failure alike, and the whole HTML-in-JSON class collapses.
🎯 Key Takeaway
A < at position 0 always means HTML arrived instead of JSON. Check status and content-type before parsing, and fix the route or session behind the page.

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.

almost-json.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
const bad = ['{"a":1,}', "{'a':1}", '{"a": undefined}'];
for (const s of bad) {
  try {
    JSON.parse(s);
  } catch (err) {
    console.log(JSON.stringify(s), '->', err.message);
  }
}

const good = JSON.stringify({ a: 1, name: "o'brien" });
console.log('serialized:', good);
console.log('round trip:', JSON.parse(good));
Try it live
📊 Production Insight
Startup crashes from a trailing comma in config waste entire deploy windows because the app dies before logging initializes. Lint every JSON config in CI with json.tool so malformed files fail the build instead of the boot.
🎯 Key Takeaway
Trailing commas, single quotes, comments, and unquoted keys are legal JS but illegal JSON. Serialize with JSON.stringify and lint config files in CI.

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.

bom-strip.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
const polluted = '\uFEFF{"a":1}';
console.log('first char code:', polluted.charCodeAt(0));

try {
  JSON.parse(polluted);
} catch (err) {
  console.log('before strip:', err.message);
}

const clean = polluted.replace(/^\uFEFF/, '');
console.log('after strip:', JSON.parse(clean));
Try it live
📊 Production Insight
Windows-born config files are the classic BOM vector in mixed-OS teams. One .editorconfig enforcing charset = utf-8 without BOM plus a CI byte check ends cross-platform recurrence permanently.
🎯 Key Takeaway
Invisible prefix bytes fail position 0 on visually perfect text. Confirm with charCodeAt, strip the BOM at ingestion, and save files without it.

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.

empty-body.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function parseBody(text, url, allowEmpty) {
  if (!text.trim()) {
    if (allowEmpty) return null;
    throw new Error('empty body from ' + url);
  }
  return JSON.parse(text);
}

console.log('204 delete:', parseBody('', '/items/1', true));
try {
  parseBody('', '/items', false);
} catch (err) {
  console.log('caught:', err.message);
}
console.log('normal:', parseBody('{"ok":true}', '/items', false));
Try it live
📊 Production Insight
DELETE endpoints that return 204 crash more frontends than any other empty-body source, because generated clients default to .json() on every response. Code-generate empty-aware handling per status code and an entire ticket category vanishes.
🎯 Key Takeaway
Never parse without checking length first. Return a documented default for legitimately empty endpoints and raise a named error for the rest.

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.

💡One Lint Line Per JSON File
Add python3 -m json.tool as a CI step over every checked-in .json file. It costs milliseconds, reports exact line and column, and stops malformed configs from ever reaching a runtime.
📊 Production Insight
The teams that suffer least from this error treat response bodies as untrusted input with shape assertions at the boundary. Parse, validate shape, then trust. Anything downstream of an unvalidated parse inherits its fragility.
🎯 Key Takeaway
Lint configs in CI, snapshot critical payloads in tests, and alert on parse-failure spikes with raw body prefixes attached.
● Production incidentPOST-MORTEMseverity: high

A Login Page in JSON Clothing Broke Search for 34 Minutes

Symptom
At 9:41 AM, site search success dropped from 99.2 percent to 4 percent for 34 minutes. Browsers logged Unexpected token < in JSON at position 0 on every query. Backend search latency stayed at 60 ms and the status dashboard showed all green, because the search service itself was healthy and the HTML came from an auth proxy in front of it.
Assumption
The team assumed a search deploy had shipped malformed JSON, since the errors began 6 minutes after a search-service release. They rolled that release back at 9:52 AM with zero effect. Uptime monitors missed it because they asserted HTTP 200, and the proxy's login page also returns 200.
Root cause
A session-cookie rotation at the auth proxy expired at 9:35 AM and began redirecting API calls to its HTML login page with a 200 status. The frontend called 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.
Fix
At 10:15 AM the proxy session was refreshed and search recovered instantly. The frontend then gained a guard that checks res.ok and content-type before parsing, logging the first 120 characters of unexpected bodies. Monitors switched from status-only to content-type plus body-shape assertions, and the proxy now returns 401 JSON instead of 200 HTML on API paths.
Key lesson
  • 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.
Production debug guideSix steps that turn the position number into the exact bad byte.6 entries
Symptom · 01
Console shows Unexpected token X in JSON at position N
Fix
Log the raw text before parsing: const text = await 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.
Symptom · 02
The token is < at position 0
Fix
You received HTML, not JSON. Check res.url for redirects to login pages, confirm the endpoint path, and read res.status. Fix the request or the server route, and add a content-type check so HTML never reaches the parser again.
Symptom · 03
The failure comes from a local .json file or config
Fix
Validate the file directly: python3 -m json.tool config.json. The tool reports the exact line and column. Remove trailing commas, convert single quotes to double, and delete comments, then re-run until the tool prints the parsed document.
Symptom · 04
The text looks valid but position 0 still fails
Fix
Suspect invisible bytes. Log text.charCodeAt(0): 65279 means a BOM prefix. Strip it with text.replace(/^\uFEFF/, "") before parsing, and save the source file as UTF-8 without BOM to stop it recurring.
Symptom · 05
The body is empty or the status is 204
Fix
Check text.length before parsing and skip JSON.parse on empty strings. Treat 204 No Content as success-without-body in your wrapper. Empty string is never valid JSON, so the guard belongs before the parse call, not in a catch.
Symptom · 06
You need a permanent client-side fix
Fix
Route all parsing through one safeJson wrapper that asserts res.ok, asserts content-type includes application/json, rejects empty bodies, and catches SyntaxError with the first 120 characters logged. One wrapper protects every call site at once.
Unexpected Token Causes Compared
Root CauseHow to ConfirmFixPrevention
HTML page fetched instead of JSON< at position 0 plus html in the raw textFix the URL, session, or routeAssert content-type before every parse
Trailing comma or single quotesToken , } or ' with valid text around itRemove the comma or use double quotesLint files with json.tool in CI
BOM or invisible prefix bytescharCodeAt(0) is 65279 on clean-looking textStrip ^\uFEFF at ingestionSave UTF-8 without BOM plus CI byte check
Empty body on 204 or error pathZero-length text with end-of-input messageReturn a default or raise a named errorEmpty-aware handling per status code
Hand-concatenated JSON stringsBreaks only when values hold quotes or newlinesBuild objects and JSON.stringify onceBan string-built JSON in code review
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
parse-anatomy.jsfunction inspect(input) {Reading the Error
safe-json.jsasync function safeJson(res) {The < Giveaway
almost-json.jsconst bad = ['{"a":1,}', "{'a':1}", '{"a": undefined}'];Trailing Commas, Single Quotes, and Other Almost-JSON
bom-strip.jsconst polluted = '\uFEFF{"a":1}';BOM and Invisible Characters Hiding at Position 0
empty-body.jsfunction parseBody(text, url, allowEmpty) {Empty Bodies and 204s

Key takeaways

1
The token names the bad byte and the position names its offset in the raw text.
2
A < at position 0 always means an HTML page arrived instead of JSON.
3
Trailing commas and single quotes are legal JS but illegal JSON.
4
Invisible BOM bytes fail position 0 on visually perfect text.
5
Empty strings never parse, so guard length before every parse.
6
Read text once, validate status and type, then parse from the same copy.

Common mistakes to avoid

5 patterns
×

Chaining res.json() without reading the raw text

Symptom
A bare position number with no idea what the server actually sent.
Fix
Read res.text() first, log the first 200 characters, then parse the same copy.
×

Trusting HTTP 200 to mean JSON

Symptom
Login walls and fallback pages parse-crash despite successful statuses.
Fix
Require content-type to include application/json before parsing anything.
×

Building JSON with string concatenation

Symptom
Payloads break whenever a value contains quotes, backslashes, or newlines.
Fix
Construct objects and serialize once with JSON.stringify.
×

Parsing without an empty-body guard

Symptom
204 responses and empty error paths throw on zero-length input.
Fix
Check text length first and return a documented default for empty endpoints.
×

Reading the response stream twice

Symptom
Logging with res.text() then parsing with res.json() fails on the exhausted stream.
Fix
Read once into a text variable and derive both the log line and the parse from it.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does Unexpected token < in JSON at position 0 mean?
Q02JUNIOR
Why does {"a": 1,} fail in JSON.parse but work as a JS literal?
Q03SENIOR
How do you debug a failure at an arbitrary position N?
Q04SENIOR
The text looks valid but position 0 still fails. What now?
Q05SENIOR
How should a client handle 204 No Content without crashing?
Q01 of 05JUNIOR

What does Unexpected token < in JSON at position 0 mean?

ANSWER
The body starts with a less-than sign, so it is HTML rather than JSON. JSON never begins with <. The usual sources are 404 pages, login walls, proxy errors, or SPA fallback routes, and the fix is checking status and content-type before parsing.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Why does the position number sometimes look wrong?
02
Can comments appear in JSON?
03
Why does my API return valid JSON in curl but break the app?
04
Is try/catch around JSON.parse enough?
05
How do I validate a large JSON file quickly?
06
Should the server or client own this fix?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's Basics. Mark it forged?

5 min read · try the examples if you haven't

Previous
EADDRINUSE Port Already in Use Fix
3 / 3 · Basics
Next
Mixed Content Blocked Fix