JSON / API Response Debugging workflow

Why JSON.parse Fails After a Successful Fetch Request

Debug cases where fetch succeeds but JSON parsing fails because the body is empty, HTML, malformed, already consumed or not actually JSON.

Quick Answer

A successful fetch request only means the browser received an HTTP response. It does not prove that the body is valid JSON. JSON.parse or response.json() can still fail when the server returns HTML, a 204 empty response, malformed JSON, a truncated body, the wrong Content-Type, or a response stream that was already read.

Example Scenario

A frontend developer copies a working endpoint from the Network panel, sees a 200 status, and still gets SyntaxError: Unexpected token < in JSON at position 0 or Unexpected end of JSON input. The request did not fail at the network layer. The failure happened later, when client code tried to interpret the response body as JSON.

Step-by-Step Explanation

  1. Check the HTTP status code before parsing the body.
  2. Check the Content-Type response header and confirm it is JSON or JSON-compatible.
  3. Read the raw response text once so you can inspect what the server actually returned.
  4. Handle 204 and other empty-body responses before calling response.json().
  5. Look for redirects, login pages, proxy error pages and framework HTML error screens.
  6. Validate copied JSON with the JSON Formatter & Validator before changing application code.

The Fetch Request Succeeded, but the Data Contract Did Not

The most common misunderstanding is treating fetch success as JSON success. The fetch API resolves its promise when the browser receives a response, even if the status is 400, 401, 500 or a redirect that eventually returns an HTML page. The HTTP layer and the JSON parsing layer are separate. A response can be reachable, cacheable and visible in DevTools while still being impossible to parse as JSON.

When developers say “the request worked but JSON.parse failed,” the next question should be: what exact bytes did the response body contain? The answer is often not JSON at all. It might be an HTML login screen, a framework error page, an empty 204 response, a plain text error, or a JSON-like string with comments or trailing commas.

Start with the Raw Response Text

When response.json() throws, temporarily switch to response.text() while debugging. This lets you inspect the raw body without assuming the format. Do not keep both response.json() and response.text() on the same response object unless you clone it first, because response bodies are streams and can only be consumed once.

The first few characters usually reveal the problem. A body that starts with < is probably HTML. An empty string explains Unexpected end of JSON input. A body that starts with { or [ may still be malformed, but at least it is likely intended to be JSON. Copy that text into the JSON Formatter & Validator and check the exact syntax error.

Check Status and Content-Type Together

Status code and Content-Type are both needed. A 200 response with text/html can happen when a server-side route returns an error page with a successful status. A 401 response with text/html can happen when an authentication layer redirects to login. A 415 response can happen because the request sent JSON without the right Content-Type header.

For JSON APIs, application/json is the usual response type. application/problem+json is also JSON and is often used for structured error responses. If Content-Type is text/html, text/plain or missing, parse carefully and inspect the body before assuming the backend returned a valid API payload.

Handle Empty Responses Explicitly

A 204 No Content response is a classic trap. It means the request succeeded and there is intentionally no response body. Calling response.json() on that response asks the browser to parse nothing as JSON, which commonly produces Unexpected end of JSON input.

The fix is not to make the server return fake JSON unless your API contract requires it. The client can branch on status 204 or check the raw text length before parsing. Empty 200 responses should be reviewed more carefully, because they may indicate a backend bug or a proxy issue.

Do Not Ignore Redirects and Authentication

If an API call silently follows a redirect to an HTML page, the final response may look successful from a network perspective but fail JSON parsing. This happens with expired sessions, missing Authorization headers, reverse proxies and applications that share routes between browser pages and API endpoints.

Open the Network panel and check the final URL, status history and response preview. If the body contains a login form or an HTML document, the JSON parser is working correctly. The client is simply receiving the wrong type of response.

Validate Before Rewriting the Client

Once you have the raw body, validate the data before changing client logic. If the body is malformed JSON, the client should not hide that problem. If the body is valid JSON but has an unexpected shape, then the issue is a schema or application contract problem rather than a parser problem.

A useful debugging habit is to save three facts together: status code, Content-Type and the first 200 characters of the body. That small snapshot usually points to the right layer: request construction, authentication, server route, response formatting or client parsing.

What to Check Next

If the raw body is valid JSON, move from parsing to contract validation. Check whether the field names, nesting, null values and array shapes match what the client expects. A parser error is gone at that point; the remaining problem is usually a mismatch between the API contract and the rendering or data-access code.

If the raw body is not JSON, do not patch the client with broad try/catch blocks until you understand why. Compare the failing request with a successful request, including URL, method, headers, cookies and environment. A small difference such as a missing Accept header, expired session cookie, wrong base URL or unexpected redirect can fully explain why the parser received the wrong body.

Code Examples

Debug with raw text before parsing JSON
const response = await fetch('/api/profile');
const text = await response.text();

console.log('status:', response.status);
console.log('content-type:', response.headers.get('content-type'));
console.log('body preview:', text.slice(0, 200));

const data = text ? JSON.parse(text) : null;
Handle 204 responses before calling response.json()
async function readJson(response) {
  if (response.status === 204) {
    return null;
  }

  const text = await response.text();
  if (!text.trim()) {
    return null;
  }

  return JSON.parse(text);
}
Check Content-Type before selecting the parser
const response = await fetch('/api/orders');
const contentType = response.headers.get('content-type') || '';

if (contentType.includes('application/json') || contentType.includes('application/problem+json')) {
  const payload = await response.json();
  console.log(payload);
} else {
  const text = await response.text();
  throw new Error('Expected JSON but received: ' + text.slice(0, 120));
}

Common Mistakes

  • Calling response.json() on every response without checking status or body length.
  • Assuming a 200 status means the body is valid JSON.
  • Reading response.text() for logging and then trying to call response.json() on the same response.
  • Ignoring Content-Type when a proxy, auth layer or framework returns HTML.
  • Fixing client parsing logic before validating the raw response body.

FAQ

Why does Unexpected token < usually mean HTML?

Most HTML documents start with <, such as <!doctype html> or <html>. If a JSON parser reports < at position 0, the response is often an HTML page instead of JSON.

Can response.json() fail on a 200 response?

Yes. A 200 status only confirms HTTP success. The response body can still be empty, malformed, mislabeled or not JSON.

Should I always check Content-Type before parsing?

It is a good debugging habit. In production code, checking Content-Type and status can make error messages clearer and prevent confusing parser failures.

Why can I not call response.text() and response.json() together?

A response body is a stream. Once it is consumed by text() or json(), it cannot be read again unless you clone the response first.