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().
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);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.
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();
}bodyaccepts a string,Blob,ArrayBuffer,FormData,URLSearchParamsor aReadableStream. 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().
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.
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".
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);
}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.
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.
// 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 });