fetch() is JavaScript's built-in way to make HTTP requests, and it needs no library at all. It returns a promise for a response object, the response object gives you the status and headers, and a second step reads the body. Once you understand that two-step shape, sending data, checking for failure and cancelling a hanging request are all straightforward.
What does fetch actually return?
A call to fetch() returns a promise immediately, before any bytes have arrived. When that promise resolves you get a Response object. The response knows the status code, the headers and the URL the request ended up at — but not the body yet, because the body is still arriving over the network.
const response = await fetch('/api/products?limit=2');
console.log(response.status); // 200
console.log(response.ok); // true
console.log(response.headers.get('content-type')); // application/json
const data = await response.json();
console.log(data.total); // 2
Because fetch returns a promise, top-level await needs a module or an async function around it. In the browser, put the code in a <script type="module"> tag; on a server, use an async function or an ES module. A script loaded the old way has no top-level await, and that is one of the more confusing errors you can hit while learning this.
Why is reading the body a separate step?
The body might be JSON, might be plain text, might be a stream of bytes, or might not exist at all. The response object therefore does not guess. You ask for the shape you expect: response.json(), response.text(), response.blob() or response.arrayBuffer(). Each returns a promise, and each can be read exactly once.
// ✗ response.json() returns a promise, and it was never awaited
const response = await fetch('/api/products');
const data = response.json();
console.log(data.total); // undefined
// ✓ the body is read in its own step
const second = await fetch('/api/products');
const parsed = await second.json();
console.log(parsed.total); // 2
Calling response.json() twice on the same response throws, because the body has already been consumed. If you need the parsed data in two places, store the parsed value rather than the response.
How do you send data with fetch?
The second argument is the options object. The three parts you will use most are method, headers and body. A request body has to be text, so JSON objects are stringified first.
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
productId: 42,
quantity: 3,
}),
});
console.log(response.status); // 201
const order = await response.json();
console.log(order.id, order.status); // 1042 created
Two details catch people out. First, the Content-Type header tells the server how to read the body; without it, a server may try to parse your JSON as form data and fail. Second, GET and HEAD requests cannot carry a body at all, so putting one there is an error rather than a silent no-op.
How do you check whether the request worked?
A network failure rejects the promise, but an HTTP error status does not. A 404 or a 500 arrives as a perfectly successful fetch; only the status says it went wrong. This trips up almost everyone once, because the code looks like it worked.
Rather than repeat that check everywhere, wrap it in one function that throws when the status is bad.
// api.js
export async function requestJson(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const problem = await response.text();
throw new Error(`HTTP ${response.status} — ${problem.slice(0, 120)}`);
}
return response.json();
}
// app.js
import { requestJson } from './api.js';
try {
const products = await requestJson('/api/products');
console.log(products.total); // 2
} catch (error) {
console.error(error.message); // HTTP 404 — plus the server's error body
}
response.ok is true for any status in the 200 range, which covers 200, 201 and 204. Reading the error body as text and putting the first 120 characters in the message keeps a huge HTML error page out of your logs.
How do you stop a request that hangs?
A server that never answers leaves the promise pending forever, and a spinner on screen forever with it. fetch supports an abort signal, and the simplest version is a deadline.
// a deadline the browser enforces for you
try {
const response = await fetch('/api/slow-report', { signal: AbortSignal.timeout(3000) });
console.log(response.status);
} catch (error) {
console.log(error.name); // TimeoutError
}
// an AbortController you can cancel yourself
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
await fetch('/api/slow-report', { signal: controller.signal });
} catch (error) {
console.log(error.name); // AbortError
} finally {
clearTimeout(timer);
}
The controller version is the one you want for anything user-driven, because the same signal can be aborted when the user navigates away or starts a newer request that makes the old one pointless. Two details are worth noting: an aborted request rejects with an error whose name is AbortError or TimeoutError, so a blanket catch will treat a cancellation the same as a real failure; and the browser sends the request anyway, it simply stops waiting for the answer.
Why does a request work in a test but fail in the browser?
Most fetch calls are same-origin: the page and the API are served from the same host, and the browser lets them through. Call a different host and the browser applies the same-origin policy. The server has to opt in by sending an Access-Control-Allow-Origin header, and if you want cookies sent along you also need Access-Control-Allow-Credentials and a specific origin rather than the wildcard.
// same origin — the browser sends the page's cookies by default
const same = await fetch('/api/me', { credentials: 'same-origin' });
console.log(same.status); // 200
// cross origin — the server must allow this origin and allow credentials.
// Point this at a second origin to watch the CORS handshake:
// await fetch('https://api.example.com/me', { credentials: 'include' });
Some requests trigger a preflight. The browser first sends an OPTIONS request asking whether the real request is allowed, and only sends the real request if the answer says yes. A GET with no extra headers is a simple request and skips that step; a POST with a JSON content type, a custom header or a method other than GET usually does not. When a request fails in the browser but succeeds in a terminal, the browser console almost always shows the preflight and the server's response headers, and that is where the answer is.
What to remember about fetch
- fetch returns a promise for a response; the body is read in a second step, and only once.
- An HTTP error status does not reject the promise. Check
response.okyourself, ideally in a shared helper. - Send JSON with
method,headersand aJSON.stringifybody. - Always give a request a deadline.
AbortSignal.timeout(ms)covers the simple case. - Cross-origin requests are subject to CORS, so the server has to allow your origin before the browser hands you the response.
Try it yourself: Run a tiny local server that answers GET /api/products with JSON, then point the first block at it. Add a POST /api/orders route that echoes the body back, and confirm the third block prints 1042 created. Then change the route to return a 500 and watch how the fourth block's error message appears only because you checked response.ok — remove that check and the same call looks like a success.