Fetch & AbortController
Real network calls. Cancellation. Race conditions.
fetchis the modern way to make HTTP requests in JS, and it lives in both browsers and Node. It's simpler than the old XMLHttpRequestbut has a few gotchas that bite absolutely everyone the first time. Let's get them out of the way.
The basic shape
const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
const user = await res.json();
console.log(user.name); // "Leanne Graham"Two awaits: one for the headers (which is what fetch actually resolves with), and one for the body. The body stream is parsed by methods like .json(), .text(), .blob(), .arrayBuffer().
Gotcha #1: status codes don't throw
fetch only rejects on network failure: DNS down, offline, CORS blocked. A 404 or 500 from the server is a perfectly successful HTTP exchange as far as fetch is concerned. You have to check res.ok yourself:
async function loadUser(id) {
const res = await fetch("/api/users/" + id);
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.json();
}.json() in a confusing way.Sending data: method, headers, body
const res = await fetch("/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token,
},
body: JSON.stringify({ title: "hi", body: "hello world" }),
});
if (!res.ok) throw new Error("Failed: " + res.status);
const created = await res.json();For form data, skip the JSON ceremony and pass a FormData directly (the browser sets the right header):
const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("name", "avatar");
await fetch("/upload", { method: "POST", body: form });AbortController: cancel a request
Most fetches in a real app should be cancellable. The user navigated away, typed a new query, or you raced two endpoints. Wire up an AbortController:
const controller = new AbortController();
const req = fetch("/api/slow", { signal: controller.signal });
// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);
try {
const res = await req;
// ...
} catch (err) {
if (err.name === "AbortError") {
console.log("user cancelled");
} else {
throw err;
}
}The modern shortcut: AbortSignal.timeout
If you only need a timeout, the newer AbortSignal.timeout(ms) creates a signal that fires on its own:
const res = await fetch("/api/slow", {
signal: AbortSignal.timeout(5000),
});AbortSignal.any: combine multiple signals
ES2024 added AbortSignal.any([...])for "cancel if any of these fire". Common pattern: user-cancel or timeout:
const userCancel = new AbortController();
cancelButton.addEventListener("click", () => userCancel.abort());
await fetch("/api/data", {
signal: AbortSignal.any([
userCancel.signal,
AbortSignal.timeout(10_000),
]),
});A reusable wrapper
Most apps end up with a thin http helper that handles the boring parts:
async function http(url, options = {}) {
const res = await fetch(url, {
headers: { "Content-Type": "application/json", ...options.headers },
...options,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`);
}
// No content? Don't try to parse JSON.
if (res.status === 204) return null;
return res.json();
}
// Usage
const users = await http("/api/users");
const created = await http("/api/posts", {
method: "POST",
body: JSON.stringify({ title: "hello" }),
});Try it live
The playground hits the free JSONPlaceholder API. Click around, change the user id, try a deliberately bad URL to see the error path. The abort button cancels mid-flight.
Quiz
When does fetch() reject (throw if awaited)?
Recap
fetchresolves on any HTTP response. Checkres.okfor success.- Two awaits:
fetch()for headers,res.json()for body. - Pass
{ signal }from anAbortControllerto make requests cancellable. AbortSignal.timeout(ms)andAbortSignal.any([...])compose nicely.- A 20-line
httphelper saves a thousand bugs.