Skip to content
JavaScript

Fetch JSON in JavaScript: response.json(), Errors, and Async Patterns

·8 min read
Fetch JSON in JavaScript: response.json(), Errors, and Async Patterns - JavaScript guide from JSON Prettifier

Quick answer: Fetch JSON with const res = await fetch(url); if (!res.ok) throw new Error(res.status); const data = await res.json();. The ok check is not optional: fetch() resolves on every HTTP status, including 404 and 500, and only rejects on network failure. res.json() is a second promise that rejects if the body is not valid JSON, which is exactly what happens when a server returns an HTML error page.

The dashboard worked for a week. Then the session expired, the API started returning its login page with a 200 status, and every widget died with SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON. The fetch code had no bug in the happy path. It had no code at all for the other paths, and fetch has more of them than people expect.

If the same endpoint works in curl or Postman but the browser reports TypeError: Failed to fetch, follow the JSON API CORS mistakes checklist before debugging the response body. A CORS block can prevent JavaScript from seeing the status, headers, or JSON at all.

For slow or stalled endpoints, the JSON API timeout mistakes guide explains why the deadline must remain active through response.json(), how to combine cancellation signals, and when a retry can duplicate a completed write.

Explore the resulting JSON in the JSON Viewer, or select nested fields with the JSONPath Query Tester.

The Minimal Correct Pattern

If the client reuses GET responses, the guide to JSON API caching mistakes covers private data, Cache-Control, Vary, ETags, invalidation, and cached errors.

When the endpoint returns more than one page, pair these response checks with a documented pagination loop. The guide to JSON API pagination mistakes shows why clients should follow the server's next link and detect repeated cursors instead of guessing when a short page means "done."

async function getJson(url) {
  const res = await fetch(url);
  if (!res.ok) {
    throw new Error("HTTP " + res.status + " for " + url);
  }
  return res.json();
}

const user = await getJson("/api/users/42");

Three things happen in order. fetch() resolves once the response headers arrive; the body is still streaming. res.ok is true for statuses 200 to 299. res.json() reads the rest of the body and runs JSON.parse on it, returning a promise because the body may still be in flight. Everything else in this post is about what goes wrong at each of those three steps.

Why fetch Does Not Reject on 404

The promise returned by fetch() is about the request, not the answer. If the browser managed to send the request and got any HTTP response back, that is a success from the network layer's point of view. It rejects, with a TypeError, only when there was no response at all: DNS failure, connection refused, the user went offline, or a CORS policy blocked the browser from reading the result.

What happenedWhat you observeHow to handle it
Server unreachable, DNS fails, CORS blockedfetch() rejects with TypeErrorCatch around await fetch()
HTTP 404 or 500 with a JSON bodyfetch() resolves; response.ok is falseCheck response.ok, then read the body
HTTP 200 but body is HTMLresponse.json() rejects with SyntaxErrorCheck Content-Type or read text first
HTTP 204 or empty bodyresponse.json() rejects with SyntaxErrorSkip parsing when status is 204
Request takes too longHangs; fetch has no default timeoutAbortSignal.timeout()

This differs from Axios and most server-side HTTP clients, which do reject on 4xx and 5xx. If you are migrating from one of those, the missing ok check is the first thing to look for.

Why response.json() Can Throw

res.json() is JSON.parse with a stream in front of it, and it fails the same ways JSON.parse does. The usual causes:

  • HTML came back. Login redirects, 404 pages, nginx or Cloudflare error pages, and misconfigured dev proxies all return HTML, often with a 200 status. The error starts with Unexpected token '<'. The unexpected token at position 0 guide maps each leading character to its cause.
  • Empty body. A 204 No Content, a HEAD request, or an endpoint that returns nothing on success. The error is Unexpected end of JSON input.
  • Genuinely broken JSON from a server bug, such as a trailing comma or a stack trace appended after the payload.

A defensive version checks the content type before committing to a parse:

async function getJson(url, init) {
  const res = await fetch(url, init);
  const type = res.headers.get("content-type") || "";

  if (!res.ok) {
    const body = type.includes("application/json") ? await res.json() : await res.text();
    throw new Error("HTTP " + res.status + ": " + (body.message || body));
  }
  if (res.status === 204) return null;
  if (!type.includes("application/json")) {
    throw new Error("Expected JSON but got " + type);
  }
  return res.json();
}

Match with includes rather than ===, because servers often send application/json; charset=utf-8 or a vendor type such as application/problem+json. The JSON content type post covers which variants you will meet.

When you are debugging and want to see the raw body regardless, use await res.text(), log it, and paste it into JSON Prettifier. If the tool reports an error, the server is the problem, not your client.

POST a JSON Body

const res = await fetch("/api/orders", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ sku: "A-100", qty: 2 }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const order = await res.json();

Both lines are required. Without JSON.stringify, the body is the string [object Object]. Without the Content-Type header, many frameworks (Express with express.json(), for one) do not parse the body at all and req.body is empty or undefined. Note that setting Content-Type: application/json makes the request "non-simple" under CORS, so a cross-origin API must answer a preflight OPTIONS request.PUT and PATCH work identically; the JSON in REST APIs guide covers the server side.

Timeouts With AbortController

fetch has no default timeout. A request to a hung server waits as long as the browser or Node lets it, which can be minutes. The modern one-liner:

const res = await fetch(url, { signal: AbortSignal.timeout(8000) });

When the timer fires, fetch rejects with a DOMException named TimeoutError. For cancellation you control, such as abandoning a request when the user types another search term, use a controller:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);

try {
  const res = await fetch(url, { signal: controller.signal });
  return await res.json();
} catch (err) {
  if (err.name === "AbortError") console.log("request cancelled");
  else throw err;
} finally {
  clearTimeout(timer);
}

The signal also cancels the body read, so a slow res.json() on a huge response is covered, not just the initial connection.

A Simple Retry Pattern

When retries are caused by provider quotas, use the dedicated guide to JSON API rate-limit mistakes for Retry-After parsing, jitter, shared concurrency, pagination checkpoints, and total retry deadlines.

Retry only on failures that might succeed next time: network errors, 408, 429, and 5xx. Never retry a 400 or 404, and be careful retrying a non-idempotent POST.

async function fetchJsonWithRetry(url, init = {}, retries = 3) {
  for (let attempt = 0; ; attempt++) {
    try {
      const res = await fetch(url, { ...init, signal: AbortSignal.timeout(8000) });
      if (res.ok) return await res.json();
      const retryable = res.status === 408 || res.status === 429 || res.status >= 500;
      if (!retryable || attempt >= retries) throw new Error("HTTP " + res.status);
    } catch (err) {
      if (attempt >= retries) throw err;
    }
    await new Promise((r) => setTimeout(r, 300 * 2 ** attempt));
  }
}

The delay doubles each attempt (300 ms, 600 ms, 1200 ms). Production code usually adds jitter and honours a Retry-After header on 429, but this shape is the core of every retry helper.

Parallel Requests With Promise.all

Awaiting fetches one after another serializes them. When the requests are independent, start them all and wait once:

const [user, orders, prefs] = await Promise.all([
  getJson("/api/users/42"),
  getJson("/api/users/42/orders"),
  getJson("/api/users/42/prefs"),
]);

Promise.all rejects as soon as any request fails. If partial results are acceptable, use Promise.allSettled and inspect each status. For a list of unknown length, do not fire hundreds of requests at once; batch them or use a small concurrency limit.

Typing the Response in TypeScript

res.json() returns Promise<any>. That is honest, since the runtime cannot know what the server sent, but it leaks any through your code. A generic wrapper gives you a declared type at the call site (if you need to write that type from a sample payload first, see JSON to TypeScript types):

interface User {
  id: number;
  name: string;
  email: string;
}

async function getJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error("HTTP " + res.status);
  return (await res.json()) as T;
}

const user = await getJson<User>("/api/users/42");
user.name; // typed as string

The cast is a promise, not a proof. If the API changes id to a string, nothing fails until something downstream does. When the API is outside your control, validate the shape at runtime with a schema library (zod, valibot, or a JSON Schema validator) in the same wrapper, so bad data is rejected at the boundary with a clear message.

Gotchas

"body stream already read"

A Response body is a stream, and a stream can be consumed once. This fails:

const text = await res.text();   // consumes the body
const data = await res.json();   // TypeError: Failed to execute 'json' on 'Response': body stream already read

Node prints a shorter TypeError: Body is unusable. Read the body once. If you want both the raw text (for logging) and the parsed object, call res.text() and then JSON.parse(text) yourself, or call res.clone() before the first read.

Forgetting the second await

const data = res.json() without await gives you a pending promise, and data.name is undefined. Both fetch() and json() are async.

Reading res.json() inside a catch for the error body

Error responses often carry useful JSON ({ "error": "Order not found" }), but not always. If the error came from a proxy, it is HTML, and a bare res.json() in your error handler throws a second error that hides the first. The content-type check in the defensive helper above exists for this case.

Dates arrive as strings

JSON has no date type, so createdAt is a string after parsing. Convert it explicitly with new Date(value); the JSON date format guide explains which formats parse reliably.

Frequently Asked Questions

How do I get JSON from a fetch response in JavaScript?
Call await response.json() after checking response.ok. The method reads the body stream and parses it with JSON.parse, so it returns a promise that resolves to the parsed object or rejects with a SyntaxError if the body is not valid JSON.
Why does fetch not throw an error on a 404?
fetch() only rejects when the request itself fails, for example on a network error or a CORS block. Any HTTP response, including 404 and 500, counts as success for the promise. You have to check response.ok or response.status yourself.
Why does response.json() fail with 'Unexpected token < in JSON'?
The server sent HTML instead of JSON, usually a login page, a 404 page, or a proxy error page. The leading < is the start of an HTML tag. Check the Content-Type header or read the body with response.text() first to see what actually came back.
How do I send JSON in a fetch POST request?
Pass method: 'POST', a Content-Type: application/json header, and body: JSON.stringify(data). The body must be a string; passing an object directly sends the text [object Object].
How do I add a timeout to fetch?
Pass signal: AbortSignal.timeout(ms) in the options. When the time runs out, fetch rejects with a TimeoutError. For manual cancellation, create an AbortController and call controller.abort() instead.
What does 'body stream already read' mean?
A Response body can only be consumed once. Calling response.json() after response.text(), or calling json() twice, throws this TypeError. Read the body once into a variable, or call response.clone() before the first read if you genuinely need two passes.

Inspect the Response the Server Actually Sent

Copy the raw body from the Network tab, paste it here, and see in one click whether it is valid JSON and where it breaks. Runs entirely in your browser.

Validate an API Response