Web Platform APIs · 1 / 10
lesson 1

fetch in depth

Requests, responses, headers, and the error handling that nobody tells you about.

~ 16 min read·lesson 1 of 10
0 / 10

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.

load-articles.js
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.

Tip

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.

check your understanding
What does 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".

broken.js
/* 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).

load-articles.js
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".

Watch out

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.

create-article.js
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.

check your understanding
Your 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.

headers.js
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.

browserserverrequest: method, url, headers, bodyresponse: status, headers, body
Each fetch is a round-trip — your request goes up with method, URL, and headers; the response comes back with status, headers, and a body.

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. A Blob is an opaque chunk of bytes you can pass to URL.createObjectURL or 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.

debug-response.js
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 */
check your understanding
Your fetch call resolves but 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:

check your understanding
Which version correctly creates a new article and is robust to non-2xx responses?
check your understanding
You want to log the raw response body to figure out why parsing fails. Which option is safe to add temporarily?
next lesson →
KeepLearningcertificate
for completing
Web Platform APIs
0 of 10 read