Quick answer: Fetch JSON with
const res = await fetch(url); if (!res.ok) throw new Error(res.status); const data = await res.json();. Theokcheck 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 happened | What you observe | How to handle it |
|---|---|---|
| Server unreachable, DNS fails, CORS blocked | fetch() rejects with TypeError | Catch around await fetch() |
| HTTP 404 or 500 with a JSON body | fetch() resolves; response.ok is false | Check response.ok, then read the body |
| HTTP 200 but body is HTML | response.json() rejects with SyntaxError | Check Content-Type or read text first |
| HTTP 204 or empty body | response.json() rejects with SyntaxError | Skip parsing when status is 204 |
| Request takes too long | Hangs; fetch has no default timeout | AbortSignal.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.