JavaScript Fetch API
fetch is the modern, promise-based HTTP client built into every browser and Node 18+. It replaces XMLHttpRequest. The API is small but has a few “gotchas” that surprise newcomers — most famously, fetch does not reject on HTTP 4xx/5xx. This chapter covers the basics, JSON handling, request options, error patterns, cancellation, and a production-grade wrapper.
A Minimal Request
const res = await fetch("/api/users/1");
const user = await res.json();
console.log(user);
Two awaits because there are two async steps:
fetch(url, options)— sends the request, resolves when headers arrive.res.json()/res.text()/res.blob()— reads the body to completion.
The Response Object
const res = await fetch("/api/users/1");
res.ok; // true if status is 200-299
res.status; // 200
res.statusText; // "OK"
res.headers.get("content-type"); // "application/json; charset=utf-8"
res.url; // resolved URL (after redirects)
res.redirected; // bool
res.type; // "basic" | "cors" | "opaque" | …
Reading the Body
You can consume the body once as one of these:
| Method | Resolves to |
|---|---|
res.json() |
parsed JSON value |
res.text() |
string |
res.blob() |
Blob (e.g. for downloads) |
res.arrayBuffer() |
binary ArrayBuffer |
res.formData() |
FormData |
res.body |
a ReadableStream (for streaming) |
const data = await res.json();
const text = await res.text(); // ❌ throws — body already consumed
To do both, clone() first or just decide upfront which you need.
The fetch Doesn’t Reject on HTTP Errors
This is the most quoted Fetch gotcha:
const res = await fetch("/api/missing");
// res is a real Response — fetch did NOT throw
res.status; // 404
res.ok; // false
await res.json(); // probably throws — body isn't JSON
fetch only rejects on network failures: DNS, CORS, abort, offline. HTTP 4xx and 5xx are still successful resolutions as far as the promise is concerned. You must check res.ok yourself:
const res = await fetch("/api/users/1");
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const user = await res.json();
Warning: Always check res.ok before reading the body. This is the #1 mistake when picking up Fetch.
Request Options
const res = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ name: "Ada", age: 37 }),
credentials: "include", // "omit" | "same-origin" | "include"
cache: "no-cache",
mode: "cors", // "no-cors" | "cors" | "same-origin"
redirect: "follow", // "follow" | "error" | "manual"
signal: ctrl.signal,
});
| Option | Common values | Use |
|---|---|---|
method |
"GET", "POST", "PUT", "PATCH", "DELETE" |
HTTP verb |
headers |
object or Headers |
request headers |
body |
string, FormData, Blob, URLSearchParams, ReadableStream |
request payload |
credentials |
"include" to send cookies cross-origin |
cookies / auth |
signal |
AbortSignal |
cancellation, timeouts |
cache |
"no-cache", "reload", "force-cache" |
cache control |
Sending JSON
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ada", role: "admin" }),
});
Three things to remember:
Content-Type: application/jsonis not auto-set — you set itbodymust be a string —JSON.stringifyit- Check
res.ok, thenres.json()
Sending Form Data
const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("title", "My upload");
const res = await fetch("/api/upload", {
method: "POST",
body: form, // browser sets Content-Type with boundary
});
Do not set Content-Type yourself for FormData — the browser fills in multipart/form-data; boundary=… for you.
For URL-encoded form posts:
const params = new URLSearchParams({ q: "javascript", page: 1 });
await fetch("/search", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
Query Strings
const params = new URLSearchParams({ q: "ada", page: 2, limit: 20 });
const res = await fetch(`/api/search?${params}`);
// Or via the URL object
const url = new URL("/api/search", "https://example.com");
url.searchParams.set("q", "ada");
url.searchParams.set("page", "2");
const res2 = await fetch(url);
Headers
const h = new Headers();
h.set("Authorization", `Bearer ${token}`);
h.append("X-Trace", "abc");
h.get("Authorization");
h.has("X-Trace");
await fetch("/api", { headers: h });
A plain object works just as well for simple cases.
Cancellation and Timeouts — AbortController
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
try {
const res = await fetch("/api/slow", { signal: ctrl.signal });
const data = await res.json();
console.log(data);
} catch (err) {
if (err.name === "AbortError") console.log("aborted");
else throw err;
} finally {
clearTimeout(timer);
}
// Modern shortcut (browsers + Node 18+):
await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });
AbortSignal.timeout(ms) builds the controller for you — much shorter.
Streaming the Response
Use res.body when you want chunks as they arrive (e.g., large CSV, server-sent-events-style feeds):
const res = await fetch("/api/stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(decoder.decode(value, { stream: true }));
}
Uploading Progress (Streamed Request)
The Fetch spec supports streaming request bodies via ReadableStream, but browser support varies. For now, use XMLHttpRequest if you need a true upload progress bar — that’s the one remaining XHR niche.
A Realistic Example — A Generic API Wrapper
async function api(path, {
method = "GET",
body,
query,
headers = {},
timeout = 10_000,
signal,
} = {}) {
const url = new URL(path, location.origin);
if (query) Object.entries(query).forEach(([k, v]) =>
v !== undefined && url.searchParams.set(k, v));
const finalHeaders = { Accept: "application/json", ...headers };
let payload;
if (body !== undefined) {
if (body instanceof FormData || body instanceof Blob) {
payload = body; // let browser set Content-Type
} else {
finalHeaders["Content-Type"] = "application/json";
payload = JSON.stringify(body);
}
}
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeout);
// Combine caller's signal with ours if provided
if (signal) signal.addEventListener("abort", () => ctrl.abort(), { once: true });
try {
const res = await fetch(url, {
method, headers: finalHeaders, body: payload, signal: ctrl.signal,
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText || res.statusText}`);
}
if (res.status === 204) return null; // No Content
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) return await res.json();
if (ct.startsWith("text/")) return await res.text();
return await res.blob();
} finally {
clearTimeout(timer);
}
}
// Usage
const user = await api("/api/users/1");
const search = await api("/api/search", { query: { q: "ada", page: 2 } });
const made = await api("/api/users", { method: "POST", body: { name: "Ada" } });
Query-builder, auto-JSON, timeout, abort wiring, status-driven body parsing — about 30 lines for a production-grade client.
Common Mistakes
// ❌ Assuming a 404 throws
const res = await fetch("/missing");
const data = await res.json(); // throws on non-JSON body
// ✅ Always check
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// ❌ Setting Content-Type for FormData
fetch("/upload", { method: "POST", headers: { "Content-Type": "multipart/form-data" }, body: form });
// → breaks boundary
// ❌ Forgetting to JSON.stringify
fetch("/api", { method: "POST", body: { x: 1 } });
// → body becomes the string "[object Object]"
// ❌ Reading the body twice
const t = await res.text();
const j = await res.json(); // ❌ already consumed
// ✅ Clone if needed
const j = await res.clone().json();
// ❌ No timeout — request hangs forever on a slow server
Best Practices Checklist
- ✅ Always check
res.ok(orres.status) before reading the body - ✅ Build URLs with
URL+URLSearchParams— never string concatenate - ✅ Set
Content-Type: application/jsonandJSON.stringify(body)for JSON posts - ✅ Let the browser set Content-Type for
FormData/Blob - ✅ Add a timeout via
AbortSignal.timeout(ms)to every external call - ✅ Wrap fetch in a small project-wide
api()helper - ❌ Don’t assume non-2xx throws — it doesn’t
- ❌ Don’t consume the body twice without
clone() - ❌ Don’t ignore
AbortErrorseparately from other failures
What’s Next?
You can talk to any HTTP endpoint. Now the universal companion to async work — error handling — try/catch/finally, throwing your own errors, custom error classes, and async error patterns.