fetch in depth
Requests, responses, headers, and the error handling that nobody tells you about.
You're building a small page that needs to load a list of articles from your server. Somewhere in your code you'll write fetch("/api/articles"). The line is short. The number of things that can go wrong is not. Networks fail, servers send back errors, the body might be JSON or it might be HTML pretending to be JSON. Once you know the shape of fetch, all of that becomes routine.
fetch is the browser's built-in way to make HTTP requests. It returns a promise (a value you'll get later) that resolves to a Response (the answer the server sent back). That's the whole core idea — everything else in this lesson is the texture around it.
The two-promise dance
The first thing that surprises people: getting a response and reading the body are two separate awaits.
async function loadArticles() {
const response = await fetch("/api/articles"); /* 1: get the response */
const articles = await response.json(); /* 2: read the body */
return articles;
}Line 2 only has the headers and status — the body is still streaming. Calling response.json() reads the whole body and parses it as JSON, returning another promise. You wait twice because the network gives you the envelope before the letter.
If you forget the second await, you don't get an object — you get a promise. That bug looks like articles.title is undefined and is the rite of passage for everyone learning fetch.
You can only read the body once. Calling response.json() twice on the same response throws. If you need it twice, store it in a variable.
fetch("/api/articles") resolve to before you call .json()?Why response.ok matters
Here's the trap that bites every beginner. fetch only rejects its promise when the network itself fails — DNS lookup, connection refused, the user dropped offline. A 404 Not Found or a 500 Internal Server Error is, as far as fetch is concerned, a perfectly fine HTTP response. It resolved. The server answered. The answer was just "no".
/* This looks correct but treats 500 errors as success. */
const response = await fetch("/api/articles");
const articles = await response.json(); /* may throw if body is HTML */
return articles;If the server returns a 500 with an HTML error page, response.json() will throw — and you'll spend an hour wondering why "JSON is broken" when the real story is "the server sent you HTML and I tried to parse it as JSON".
The fix is response.ok (a boolean — true when status is 200–299) and response.status (the number).
async function loadArticles() {
const response = await fetch("/api/articles");
if (!response.ok) {
throw new Error(`Articles request failed: ${response.status}`);
}
return response.json();
}The pattern is so common that you'll write it a thousand times. Check ok first, parse second. The Error message includes the status so you can see what actually happened in the console — failed: 401 tells you "log in again", failed: 500 tells you "go yell at the backend".
Don't try-catch around fetch alone hoping to handle 404s — the catch only fires for network failures. The 404 is a successful network round-trip; you have to inspect response.ok.
Sending data with POST
By default, fetch does a GET — "give me this thing". To send data, pass a second argument with method, body, and a Content-Type header so the server knows what's coming.
async function createArticle(article) {
const response = await fetch("/api/articles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(article), /* fetch does NOT auto-stringify */
});
if (!response.ok) throw new Error(`Create failed: ${response.status}`);
return response.json();
}Two things people forget. First, body must be a string (or FormData, or a Blob) — you have to call JSON.stringify yourself. Passing body: article will silently send [object Object], and the server will return a confused 400. Second, the Content-Type header tells the server "this is JSON" — without it, many servers refuse to parse the body and respond with a 415.
POST request sends an object as the body. The server keeps responding with 400 "invalid JSON". The fetch line is fetch(url, { method: "POST", headers, body: article }). What's wrong?Headers, both sides
Headers are a small dictionary of metadata attached to requests and responses. On the request side, you set them in the options object. On the response side, you read them off response.headers.
const response = await fetch("/api/me", {
headers: {
"Authorization": "Bearer abc123",
"Accept": "application/json",
},
});
const total = response.headers.get("x-total-count"); /* read one back */
console.log(total);response.headers is not a plain object — it's a Headers instance with a .get(name) method. Header names are case-insensitive, so "X-Total-Count" and "x-total-count" both work.
JSON, text, and blob
The body of a response is just bytes. Response exposes a few methods that decode those bytes for you, depending on what they are:
response.json()— parse as JSON. Throws if the body isn't valid JSON.response.text()— get the body as a string. Useful for HTML, plain text, CSV, or for seeing what an unexpected response actually contains.response.blob()— raw binary, for images, files, downloads. ABlobis an opaque chunk of bytes you can pass toURL.createObjectURLor to other Web APIs.response.arrayBuffer()— raw bytes when you need to look at them. Less common.
When something is going wrong and you don't know why, swap .json() for .text() and console.log the result. Eight times out of ten the server sent you an HTML error page or an empty string and you've been parsing it as JSON.
const response = await fetch("/api/articles");
const raw = await response.text(); /* always succeeds */
console.log(raw); /* now you can SEE what came back */
const data = JSON.parse(raw); /* parse manually if it really is JSON */response.json() throws "Unexpected token < in JSON". What's the most likely explanation?Try it yourself
Pull the pieces together — error checking, the right method, the right headers, the right body shape: