Topic 11.2
fetch
In one line
fetch(url, options) sends an HTTP request and returns a promise for a Response; it only rejects on network failure, so you must check response.ok yourself, read the body once with json() or text(), and use an AbortController to cancel or time out.
Think of it like this
Sending a letter and getting a reply. You post a letter (the request) and get a receipt straight away: a promise that a reply will come. When the reply arrives, you first look at the envelope (status and headers) and only then open it and read the letter inside (the body). An envelope stamped "address unknown" is still a reply; only a lost letter means the post itself failed. And if you change your mind, you can phone the post office and cancel the delivery.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- fetch
- The browser function that sends an HTTP request and returns a promise for the response.
- Response
- The object fetch gives you: status, headers and a body you can read once.
- response.ok
truewhen the status is 200–299,falsefor anything else.- Body
- The data part of a response or request, like the JSON text. Reading it takes time, so it returns a promise.
- Network error
- No usable reply at all (offline, wrong address, blocked by CORS). Only this makes fetch reject.
- AbortController
- An object with a
signalyou hand to fetch and anabort()method that cancels the request. - AbortError
- The error name a fetch rejects with when you cancel it.
- TimeoutError
- The error name when
AbortSignal.timeout(ms)cancels a request because it took too long. - data: URL
- A URL that contains its own content, like
data:application/json,{"a":1}. Fetching it needs no network, so Hectal's examples use it.
Step by step
01The two awaits
First wait for the response (status and headers), then wait for the body. Forgetting the second await gives you a pending promise instead of data.
const res = await fetch("https://api.example.com/users/42");
console.log(res.status); // 200
const user = await res.json(); // second wait: download and parse the body
console.log(user.name); // "Asha"02Check ok, every time
Turn HTTP errors into real errors so they end up in your catch, just like network errors. Include the status in the message so logs are useful.
async function getJSON(url, options) {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error("HTTP " + res.status + " for " + url);
}
return res.json();
}03Send JSON with POST
Three things: the method, a Content-Type header that says the body is JSON, and the body turned into a string. A 201 Created reply often includes the saved item with its new id.
const res = await fetch("https://api.example.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Asha", city: "Pune" }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const created = await res.json(); // { id: 7, name: "Asha", city: "Pune" }04Which outcome happened?
Every request ends in one of four places. Your code decides what the user sees in each one.
05Cancel and time out
Pass a signal. AbortSignal.timeout(ms) cancels by itself; an AbortController lets you cancel, for example when the user types a new search before the old one finished. To have both, combine them with AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]).
try {
const res = await fetch("/api/report", { signal: AbortSignal.timeout(5000) });
// ...
} catch (err) {
if (err.name === "TimeoutError") showMessage("The server is slow. Try again.");
else if (err.name === "AbortError") { /* cancelled on purpose */ }
else throw err;
}06Cancel stale searches
A search box that fetches on every keystroke can show the wrong results: an old, slow reply arrives after a newer, fast one. Aborting the previous request before starting the next one fixes that race. Combine it with debouncing (Topic 9.7) to send fewer requests in the first place.
let current;
async function search(term) {
current?.abort(); // cancel the previous request
current = new AbortController();
try {
const res = await fetch("/api/search?q=" + encodeURIComponent(term), { signal: current.signal });
render(await res.json());
} catch (err) {
if (err.name !== "AbortError") throw err;
}
}Try it yourself
- 1
Change the data URL
In the first example, add
"age":30to the JSON in the URL and print the age on the page. Then break the JSON on purpose (remove the closing}) and predict which line throws: thefetchor thejson()? - 2
Read the body twice
In the first example, add
await res.text();afterres.json(). Predict the error before running. Then fix it withres.clone(). - 3
Move the timeout
In the third example, change the last timeout from
300to1000. Predict the output. Remember to keep the total under the example's 1.2 second wait.
Code & diagrams
A `data:` URL carries its own content, so this is a real `fetch` with a real `Response`, but no server. After `json()`, `bodyUsed` is `true`: the body can't be read again.
Preview
Console
Nothing logged yet.Expected console output
true 200 application/json
{ name: 'Asha', city: 'Pune' }
body used: trueThe 422 reply resolved normally; `postJSON` turned it into an error because `res.ok` was false. Only the network failure was a rejected promise from fetch itself. Clear the input and press Save to see the error on the page.
Preview
Console
Nothing logged yet.Expected console output
saved { id: 7, name: 'Asha' }
server said no: HTTP 422: name is required
network problem: Failed to fetch`abort()` with no argument rejects with an `AbortError`; `AbortSignal.timeout()` rejects with a `TimeoutError`, so your code can tell "the user cancelled" from "the server was too slow". The messages shown are Chrome's.
Preview
Console
Nothing logged yet.Expected console output
real fetch, aborted -> AbortError: signal is aborted without reason
100ms with 400ms timeout -> done after 100ms
900ms with 300ms timeout -> TimeoutError: signal timed outChecks the content type before parsing, handles 204, adds a timeout and still accepts the caller's own signal.
export async function request(url, { method = "GET", body, timeout = 8000, signal } = {}) {
const signals = [AbortSignal.timeout(timeout)];
if (signal) signals.push(signal);
const res = await fetch(url, {
method,
headers: body ? { "Content-Type": "application/json", Accept: "application/json" } : { Accept: "application/json" },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.any(signals),
});
const isJSON = res.headers.get("content-type")?.includes("application/json");
const data = res.status === 204 ? null : isJSON ? await res.json() : await res.text();
if (!res.ok) {
const err = new Error(`HTTP ${res.status} ${method} ${url}`);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to check res.ok
In the second example, delete the if (!res.ok) { ... } block from postJSON and run.
Break #2
Send an object as the body
Write body: { name: "Asha" } instead of body: JSON.stringify({ name: "Asha" }) in a real fetch.
Break #3
Give a GET request a body
Call fetch("data:,x", { method: "GET", body: "x" }).
Myth vs fact
Myth
fetch rejects when the server returns 404 or 500.
Fact
It resolves for every HTTP status. It only rejects for network failures, CORS blocks, invalid URLs and aborts. Check res.ok.
Myth
fetch has a default timeout.
Fact
It doesn't. The browser may give up after a long time on a dead connection, but a slow server can keep you waiting for minutes. Add AbortSignal.timeout().
Myth
Aborting a fetch stops the server from doing the work.
Fact
Aborting closes the connection and frees the browser side. A server that already received a POST may still finish it. Design write endpoints to be safe against that (for example with idempotency keys).
Myth
You need a library like Axios to do HTTP properly.
Fact
fetch covers everything in modern browsers and Node.js 18+. Libraries add conveniences (automatic JSON, interceptors, throwing on 4xx), which a 20-line helper gives you too.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
res.json()waits for the whole body. For large or streaming responses, readres.body(aReadableStream) withgetReader()orfor await, and parse as chunks arrive, which is how AI chat UIs show text word by word. - ▸
Upload progress isn't available on
fetchin browsers yet;XMLHttpRequeststill offersupload.onprogress. Download progress can be computed fromres.bodychunks and theContent-Lengthheader. - ▸
Browsers limit concurrent connections and prioritise requests.
fetch(url, { priority: "high" })(Fetch Priority, supported in Chromium and Safari) andkeepalive: true(lets a small request outlive the page, useful for analytics onpagehide) are worth knowing. - ▸
Error messages differ by browser: Chrome says
Failed to fetch, FirefoxNetworkError when attempting to fetch resource., SafariLoad failed. Testerr.name(TypeError,AbortError,TimeoutError), never the message text.
Remember this
- 1
fetchis the browser's built-in function for HTTP requests (it replaced the olderXMLHttpRequest).fetch(url)sends aGETand immediately returns a promise (Phase 10). The promise resolves with aResponseobject as soon as the status line and headers arrive, before the body has finished downloading. So there are always two waits: one for the response, one for the body:const res = await fetch(url); const data = await res.json();. - 2
The single most important rule: **
fetchdoes not reject on HTTP errors. A 404 or 500 is a successful conversation with a bad answer, so the promise resolves. It only rejects** (with aTypeError, messageFailed to fetchin Chrome) when no usable response arrived: you're offline, the domain doesn't exist, the connection dropped, CORS blocked it, or the URL is invalid. That's why every real fetch checks **res.ok**, which istrueonly for status 200–299, and throws its own error otherwise. - 3
A
Responsegives youstatus,statusText,ok,headers(aHeadersobject),url(after redirects),redirectedand the body. The body is a stream you can read once, with one of:res.json()(parse JSON),res.text(),res.blob()(files and images),res.arrayBuffer()orres.formData(). Each returns a promise. Reading twice throwsTypeError: ... body stream already read;res.clone()before the first read if you really need two copies. - 4
To send data, pass an options object:
fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }). Thebodymust be a string,FormData,URLSearchParams,Blobor similar; a plain object is not turned into JSON for you (it becomes the text[object Object]).GETandHEADrequests can't have a body at all. Other useful options:credentials("same-origin"default,"include"to send cookies cross-origin),cache,modeandsignal. - 5
An **
AbortController** cancels a request. Create one, passcontroller.signalas thesignaloption, and callcontroller.abort(): the fetch promise (or the body read in progress) rejects with aDOMExceptionnamed **AbortError. For timeouts**,AbortSignal.timeout(5000)gives a signal that aborts by itself after 5 seconds, rejecting with a **TimeoutError**.fetchhas no timeout of its own: without one, a hung server can keep your spinner going for minutes. - 6
Good fetch code handles four outcomes: success (2xx with the body you expected), an HTTP error (
!res.ok: show a message based on the status), a network or CORS failure (TypeError), and a cancel or timeout (AbortError/TimeoutError, usually not shown as an error at all because the user moved on). Wrap the pattern in a small helper such asgetJSON(url)so every call gets it right.
Explain it without notes
Why do you have to check response.ok? When does a fetch promise reject?
Why are there two awaits in a typical fetch call?
How do you send JSON with fetch, and what goes wrong if you skip JSON.stringify?
How do you add a timeout and cancellation to fetch, and how do you tell them apart?
Practice
Write async function getName() that fetches data:application/json,{"name":"Meera"}, checks ok, and returns the name. Log the result.
Write a fetchWithTimeout(url, ms) helper that rejects with a TimeoutError when the request takes longer than ms.
Trade-offs
- ↔
Throwing on every non-2xx response keeps calling code simple, but some statuses (like 404 for "not found yet" or 409 for a conflict) are expected; a helper that returns
{ ok, status, data }gives callers more control at the cost of more checks. - ↔
Short timeouts make the UI feel responsive and free resources, but on slow mobile networks they can fail requests that would have succeeded; pair them with a retry and a clear message.
Done when you can
I can fetch JSON with two awaits and use the data.
I always check
res.okbefore using the body.I can send JSON with POST, the right header and
JSON.stringify.I know fetch only rejects on network failures and aborts.
I can cancel a request with
AbortControllerand add a timeout withAbortSignal.timeout.I can tell
TypeError,AbortErrorandTimeoutErrorapart in acatch.