Unexpected end of JSON input in fetch() — Causes and Fixes

You call fetch(url).then(res => res.json()) and get back SyntaxError: Unexpected end of JSON input. The URL looks right, the request goes through, and yet the parse blows up. This error almost never means your JSON file is broken — it means res.json() received something that isn't JSON at all, or received nothing.

Here are the five causes, in order of how often they show up, with a fix for each.

Cause 1 — The server returned an HTML error page

This is the most common cause. Your server is returning a 401 Unauthorized, 500 Internal Server Error, or a login redirect — and the response body is HTML, not JSON. res.json() tries to parse <!DOCTYPE html>... and immediately throws because < is not valid JSON.

// The request "succeeds" (no network error), but the body is HTML
fetch('/api/data')
  .then(res => res.json())   // throws if body is "..."
  .then(data => console.log(data));

The fix: always check res.ok before parsing. res.ok is true only for 2xx status codes.

fetch('/api/data')
  .then(res => {
    if (!res.ok) {
      throw new Error(`HTTP ${res.status}: ${res.statusText}`);
    }
    return res.json();
  })
  .then(data => console.log(data))
  .catch(err => console.error(err));

To confirm this is your cause: open DevTools → Network tab → click the request → check the Response tab. If you see HTML instead of JSON, the server is sending the wrong content type.

Cause 2 — The response body is empty

A 204 No Content response, a DELETE endpoint that returns nothing, or a network error that returns an empty body all result in an empty string. JSON.parse('') throws Unexpected end of JSON input because an empty string is not valid JSON.

// 204 No Content — body is empty
fetch('/api/item/42', { method: 'DELETE' })
  .then(res => res.json())  // throws: empty string is not JSON
  .then(data => ...);

The fix: read the body as text first and only parse if it's non-empty.

fetch('/api/item/42', { method: 'DELETE' })
  .then(res => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.text();
  })
  .then(text => (text ? JSON.parse(text) : null))
  .then(data => console.log(data));

Cause 3 — You already read the response body

The fetch Response body is a readable stream — it can only be consumed once. If you call res.text() first (for example, to log the raw response for debugging), the stream is exhausted. Calling res.json() afterwards reads an empty stream and throws.

fetch('/api/data')
  .then(res => {
    res.text().then(t => console.log('raw:', t));  // consumes the stream
    return res.json();   // throws — stream is already read
  });

The fix: clone the response before reading it twice, or read as text and parse manually.

// Option A — clone the response
fetch('/api/data')
  .then(res => {
    res.clone().text().then(t => console.log('raw:', t));
    return res.json();  // original stream still intact
  });

// Option B — read as text, parse manually
fetch('/api/data')
  .then(res => res.text())
  .then(text => {
    console.log('raw:', text);
    return JSON.parse(text);
  });

Cause 4 — Truncated or partial response

A network timeout, a proxy that cuts off large responses, or a streaming endpoint that closes prematurely can deliver an incomplete JSON body. The start of the JSON is valid, but the document ends before all brackets are closed.

// What arrives in the browser:
{"users": [
  {"id": 1, "name": "Alice"},
  {"id": 2, "name": "Bob"
// ... response cut off here

The fix: read the raw text and inspect what arrived, then investigate the server or proxy configuration that's limiting response size.

fetch('/api/large-dataset')
  .then(res => res.text())
  .then(text => {
    console.log('Last 100 chars:', text.slice(-100));
    // If it ends mid-value, the response was truncated
    return JSON.parse(text);
  });

Cause 5 — CORS preflight failed silently

When a cross-origin fetch request fails the CORS preflight, the browser returns an opaque network error. res.json() on an opaque response receives an empty body and throws. The real error is in the console as a CORS policy message, not in the response body.

// Check the browser console for:
// "Access to fetch at 'https://api.example.com/...' from origin
//  'https://yoursite.com' has been blocked by CORS policy"

The fix: configure the server to send the correct Access-Control-Allow-Origin header, or proxy the request through your own server to avoid the cross-origin restriction.

The universal debugging approach

Whenever you see this error from fetch, swap res.json() for res.text() temporarily and log what you get:

fetch('/api/data')
  .then(res => {
    console.log('Status:', res.status, res.statusText);
    console.log('Content-Type:', res.headers.get('content-type'));
    return res.text();
  })
  .then(text => {
    console.log('Body (first 500):', text.slice(0, 500));
    // Now you know exactly what the server sent
  });

The first 500 characters tell you everything: if it starts with <! the server sent HTML; if it is empty, there was no body; if it looks like partial JSON, the response was truncated.

A safe fetch wrapper for production

This helper handles all five cases — it checks status, reads body once, guards against empty responses, and gives you a useful error message instead of a generic parse failure:

async function fetchJSON(url, options) {
  const res = await fetch(url, options);

  const contentType = res.headers.get('content-type') || '';
  const isJSON = contentType.includes('application/json');

  // Read body once as text
  const text = await res.text();

  if (!res.ok) {
    const detail = text.slice(0, 200);
    throw new Error(`HTTP ${res.status} ${res.statusText}: ${detail}`);
  }

  if (!text.trim()) {
    return null;  // 204 No Content or empty success response
  }

  if (!isJSON) {
    throw new Error(`Expected JSON but got: ${contentType}\n${text.slice(0, 200)}`);
  }

  return JSON.parse(text);
}

Use it as a drop-in replacement for fetch().then(res => res.json()):

const data = await fetchJSON('/api/data');
const result = await fetchJSON('/api/items', { method: 'POST', body: JSON.stringify(payload) });

FAQ

Why does fetch not throw an error for 4xx and 5xx responses?

fetch only rejects (throws) on network failures — DNS errors, connection refused, offline. HTTP error status codes like 404 or 500 resolve the promise normally. You must check res.ok or res.status yourself. This is a common source of confusion for developers coming from libraries like Axios, which throw automatically on 4xx/5xx.

Why does my API return HTML instead of JSON?

Usually because the request hit a route that doesn't exist (404 from the framework's HTML error page), or the user's session expired and the server redirected to a login page (302 → HTML). Check the status code and the Content-Type response header. See Why is my API returning HTML instead of JSON? for a full breakdown.

Can I use async/await instead of .then()?

Yes — the safe wrapper above uses async/await. The behaviour is identical, and async/await is generally easier to read and debug when multiple steps are involved.

Related articles

Inspect a JSON response instantly

Paste the raw response body into the JSON Formatter to see if it is valid JSON, where it breaks, and what the structure looks like.

Open JSON Formatter