JavaScriptFetch API

The Fetch API

fetch is the modern, promise-based way to make HTTP requests from JavaScript. It ships in every modern browser, in Node.js (18+), in Deno, in Bun and in service workers. The API is small — one function, two objects (Request and Response) — but a few of its design choices catch newcomers out, especially around error handling and bodies that can only be read once.

A basic GET

fetch(url) returns a promise that resolves with a Response. The body is a stream you decode with one of the helper methods: .text(), .json(), .blob(), .arrayBuffer(), .formData().

JS
async function loadUser(id) {
  const response = await fetch(`/api/user/${id}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}

loadUser(42).then(console.log);
fetch resolves on 404!
A `404` or `500` does **not** cause the promise to reject. Only network failures (DNS, offline, CORS errors) reject. Always check `response.ok` or `response.status` yourself before reading the body.
Options: method, headers, body

The second argument is an init object. Anything you would put on a Request lives here: method, headers, body, mode, credentials, cache, signal and more.

JS
async function createPost(post) {
  const response = await fetch("/api/posts", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(post),
    credentials: "include",   // send cookies for cross-origin
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}
  • body accepts a string, Blob, ArrayBuffer, FormData, URLSearchParams or a ReadableStream. For JSON, you stringify it yourself.

  • credentials: "include" sends cookies cross-origin; "same-origin" is the default in most browsers.

  • mode: "cors" is the default; "no-cors" produces an opaque response you cannot read.

  • cache: "no-store" skips the HTTP cache; "reload" revalidates.

Working with JSON

The pattern is almost always: send JSON with a Content-Type header, read JSON with response.json(). Each body method can only be called once — calling .json() twice on the same response throws "body stream already read". If you need the response twice (logging + parsing), call response.clone().

JS
const response = await fetch("/api/me");
const forLog = response.clone();
console.log("status", forLog.status, await forLog.text());

const me = await response.json();
console.log(me.name);
Error handling, properly

Wrap fetch in a helper that turns "bad" HTTP responses into thrown errors — your callers can then use ordinary try/catch.

JS
class HttpError extends Error {
  constructor(response) {
    super(`HTTP ${response.status} ${response.statusText}`);
    this.response = response;
  }
}

async function http(url, init) {
  let response;
  try {
    response = await fetch(url, init);
  } catch (cause) {
    throw new Error("network error", { cause });
  }
  if (!response.ok) throw new HttpError(response);
  return response;
}

try {
  const me = await http("/api/me").then((r) => r.json());
} catch (err) {
  if (err instanceof HttpError) {
    console.log("server said", err.response.status);
  } else {
    console.log("could not reach the server");
  }
}
AbortController — cancelling a fetch

Pass an AbortSignal in init.signal. Calling controller.abort() rejects the fetch promise with a DOMException whose name is "AbortError". This is the standard pattern for "user navigated away" or "newer request supersedes the old one".

JS
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

try {
  const response = await fetch("/api/slow", { signal: controller.signal });
  const data = await response.json();
} catch (err) {
  if (err.name === "AbortError") console.log("cancelled or timed out");
  else throw err;
} finally {
  clearTimeout(timer);
}
AbortSignal.timeout
Modern environments provide `AbortSignal.timeout(ms)` — a signal that aborts itself after a delay. Combined with `AbortSignal.any([...])` you can compose timeouts and user cancellation without a manual `setTimeout`.
Streaming response bodies

A Response body is a ReadableStream of Uint8Array chunks. For huge responses or progressive UIs (think streaming an LLM token-by-token) you can iterate the stream instead of buffering the whole thing.

JS
const response = await fetch("/api/stream");
const reader = response.body
  .pipeThrough(new TextDecoderStream())
  .getReader();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  process.stdout.write(value);
}

Async iteration is supported in newer runtimes — for await (const chunk of response.body) { ... } — which removes the manual reader loop.

Forms, query strings and headers

URLSearchParams and FormData are the friendly ways to build URL-encoded and multipart bodies. Browsers set Content-Type automatically for both.

JS
// GET with query parameters
const url = new URL("/api/search", location.origin);
url.searchParams.set("q", "fetch api");
url.searchParams.set("limit", 20);
const results = await fetch(url).then((r) => r.json());

// POST form data
const body = new FormData();
body.set("file", fileInput.files[0]);
body.set("note", "hello");
await fetch("/upload", { method: "POST", body });
Wrap fetch once, use it everywhere
A four-line wrapper that adds `baseURL`, JSON parsing and HTTP-error throwing is almost always worth writing. You will replace dozens of duplicated `if (!response.ok)` checks with a single call.