The single most important thing to know about fetch() is this: a 404 or a 500 is not an error as far as fetch is concerned. The promise still resolves. It only rejects when the request never completes at all, a dropped connection, DNS failure, CORS block. So this code, which looks completely reasonable, silently swallows every server error:
// BROKEN: this catch never runs for a 404 or a 500
try {
const res = await fetch("/api/user/42");
const user = await res.json(); // tries to parse the error page
} catch (err) {
console.error("request failed", err);
}You have to check response.ok yourself:
const res = await fetch("/api/user/42");
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const user = await res.json();response.ok is true for any status in the 200–299 range and false otherwise. Get that one habit right and you have sidestepped the bug that fills the most "why isn't my fetch error handling working" threads. The rest of this guide is the request/response model around it.
The request/response model
fetch(url, options) returns a promise that resolves to a Response object as soon as the headers arrive. That is the part people miss: the promise settling does not mean the body has downloaded, it means the server has started replying. The body is read separately, and reading it is itself asynchronous.
const res = await fetch("https://api.example.com/widgets");
// res is a Response. The body has NOT been read yet.
res.status; // 200
res.ok; // true
res.headers.get("content-type"); // "application/json; charset=utf-8"To get the body you call one of the Response body methods, and each of them returns a promise.
response.json() returns a promise, not your data
This is the second thing that trips people up. res.json() does not hand you the parsed object. It hands you a promise that resolves to the parsed object, because reading and parsing the body is async. You have to await it (or .then() it):
// WRONG: data is a Promise, not the object
const data = res.json();
console.log(data.name); // undefined
// RIGHT
const data = await res.json();
console.log(data.name); // "widget"The full set of body readers, all promise-returning, each usable only once per response:
await res.json(); // parsed JSON
await res.text(); // raw string
await res.blob(); // Blob (images, files)
await res.arrayBuffer(); // ArrayBuffer (binary)
await res.formData(); // FormDataThe "usable only once" part matters: the body is a stream, so calling res.json() after you already called res.text() throws body stream already read. If you need the raw text and the parsed object, read the text once and JSON.parse it yourself. (res.clone() exists for the rare case you genuinely need two independent reads.)
Putting the body gotcha together with the response.ok rule gives the canonical helper I reach for. Note the te_ prefix, it is just my own namespacing so the function does not collide:
async function te_getJSON(url, options) {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`HTTP ${res.status} on ${url}`);
}
return res.json();
}
const user = await te_getJSON("/api/user/42");POST, PUT, and sending a body
The default method is GET. For anything else, pass an options object. To send JSON you set the method, the body (a string, so JSON.stringify your object), and a Content-Type header so the server knows how to parse it:
const res = await te_getJSON("/api/widgets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "widget", price: 19 }),
});body accepts more than strings: a FormData instance (for file uploads or multipart forms, do not set Content-Type yourself in that case, the browser sets the multipart boundary for you), URLSearchParams for form-encoded data, a Blob, or an ArrayBuffer. The method value is case-insensitive but conventionally uppercase.
Headers
You can pass headers as a plain object, as above, or build a Headers instance when you want to add to them programmatically:
const headers = new Headers();
headers.set("Authorization", `Bearer ${token}`);
headers.append("Accept", "application/json");
const res = await fetch("/api/me", { headers });On the response side, res.headers is also a Headers object: res.headers.get("content-type"), res.headers.has("etag"), and it is iterable with for...of. Header names are case-insensitive, so get("Content-Type") and get("content-type") are the same.
Cookies and credentials
By default fetch sends cookies and HTTP auth on same-origin requests but not cross-origin ones. The credentials option controls this:
"same-origin"(the default): send credentials only to the same origin."include": send credentials even cross-origin. You need this for a cross-origin session-cookie API, and the server must answer with the matching CORS headers (Access-Control-Allow-Credentials: trueand a specific origin, not*)."omit": never send credentials.
const res = await fetch("https://api.example.com/me", {
credentials: "include",
});fetch is global in Node now, so node-fetch is done
If you searched for node-fetch, you almost certainly do not need it anymore. fetch has been a global in Node since v18 (April 2022), no import, no flag to enable it, and it was marked stable in Node 21. The same fetch, Response, Headers, and Request you use in the browser are there in Node:
// Node 18+, no require, no import, no node-fetch
const res = await fetch("https://api.github.com/repos/nodejs/node");
const repo = await res.json();
console.log(repo.stargazers_count);node-fetch still has a narrow place, supporting a pre-18 runtime, or a CommonJS codebase wanting a particular API shape, but for new code on a current Node, reaching for it is a reflex worth dropping.
The limitations worth knowing up front
Two gaps catch people, and both have a real answer rather than a workaround.
No built-in timeout. fetch will wait as long as the browser or runtime allows; there is no timeout option. A hung server hangs your request. The right tool is AbortController/AbortSignal, and the modern one-liner is AbortSignal.timeout(ms). I cover the full pattern, including combining a timeout with a manual cancel, in adding a timeout to fetch with AbortController.
No upload progress. fetch cannot report upload progress, there is no onprogress event. If you need a progress bar for a file upload, XMLHttpRequest (via its upload.onprogress) is still the only API that gives it to you directly. For download progress you can read the response body as a stream and count bytes as they arrive, which is also how you consume NDJSON or streaming LLM responses, see streaming a fetch response with ReadableStream.
When does fetch actually reject?
Closing the loop on where we started. The promise fetch returns rejects in a small set of cases, all of them "the request never produced an HTTP response":
- A network failure (offline, connection dropped, DNS resolution failed).
- A CORS policy block (you get a
TypeError, deliberately opaque so the page cannot probe cross-origin responses). - The request was aborted via an
AbortController(rejects with anAbortError). - A malformed URL or a few other request-construction errors.
Everything else, every status code the server actually sent back, is a successful fetch with a Response you must inspect. That is the whole reason response.ok exists, and the whole reason this guide leads with it.
FAQ
See also
- JavaScript Promises explained: a complete guide: the foundation under fetch, since every fetch call is a promise, including the async/await style this guide uses throughout.
- Add a timeout to fetch() with AbortController: the answer to fetch's missing timeout,
AbortSignal.timeout()and combining a timeout with a manual cancel. - Stream a fetch() response with ReadableStream: read the body as it arrives for download progress, NDJSON, and streaming LLM responses.
Sources
Authoritative references this article was fact-checked against.
- Using the Fetch API — MDNdeveloper.mozilla.org
- Window: fetch() method — MDNdeveloper.mozilla.org
- Response — MDNdeveloper.mozilla.org
- Node.js 18 release announcement (global fetch)nodejs.org





