webdev.complete
Async JavaScript
JavaScript
Lesson 33 of 117
25 min

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

js
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:

js
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();
}
Don't skip the res.ok check
Without it, a 404 happily gives you an empty body or an HTML error page that crashes .json() in a confusing way.

Sending data: method, headers, body

js
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):

js
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:

js
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:

js
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:

js
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:

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

const out = document.getElementById("out");
const log = msg => {
  console.log(msg);
  out.textContent += msg + "\n";
};

async function loadUser(id, signal) {
  const res = await fetch(
    "https://jsonplaceholder.typicode.com/users/" + id,
    { signal }
  );
  if (!res.ok) throw new Error("HTTP " + res.status);
  return res.json();
}

document.getElementById("go").addEventListener("click", async () => {
  out.textContent = "";
  const id = document.getElementById("id").value;

  // Cancel after 5s, OR if user clicks Cancel
  const userCancel = new AbortController();
  document.getElementById("cancel").onclick = () => userCancel.abort();

  const signal = AbortSignal.any([
    userCancel.signal,
    AbortSignal.timeout(5000),
  ]);

  try {
    const user = await loadUser(id, signal);
    log("name: " + user.name);
    log("email: " + user.email);
    log("city: " + user.address.city);
  } catch (err) {
    if (err.name === "AbortError" || err.name === "TimeoutError") {
      log("cancelled or timed out");
    } else {
      log("error: " + err.message);
    }
  }
});

Quiz

Quiz1 / 3

When does fetch() reject (throw if awaited)?

Recap

  • fetch resolves on any HTTP response. Check res.ok for success.
  • Two awaits: fetch() for headers, res.json() for body.
  • Pass { signal } from an AbortController to make requests cancellable.
  • AbortSignal.timeout(ms) and AbortSignal.any([...]) compose nicely.
  • A 20-line http helper saves a thousand bugs.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.