The shape of a request
const res = await fetch("/api/doors");
const res = await fetch("/api/doors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Painted", days: 5 }),
signal: controller.signal,
credentials: "same-origin", // "include" to send cookies cross-origin
cache: "no-store",
});
The trap: fetch does not reject on 404
// fetch only rejects on a NETWORK failure — DNS, offline, CORS, abort.
// A 404 or a 500 is a perfectly successful HTTP conversation.
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
Without the res.ok check, a 404 whose body is an HTML error page makes res.json() throw a confusing parse error instead of telling you the request failed.
Reading the body
await res.json(); // parses — throws if the body is not JSON
await res.text();
await res.blob(); // an image or a file
await res.formData();
await res.arrayBuffer();
res.status; // 200
res.ok; // status in 200–299
res.headers.get("content-type");
res.url; res.redirected;
// the body is a stream: reading it twice throws
const copy = res.clone(); // if you really need to
A wrapper worth writing once
Reading text() first and parsing yourself handles the two cases res.json() cannot: an empty body, and an error page that is not JSON.
class HttpError extends Error {
constructor(status, body) {
super(`HTTP ${status}`);
this.name = "HttpError";
this.status = status;
this.body = body;
}
}
async function api(path, { method = "GET", body, signal } = {}) {
const res = await fetch(path, {
method,
signal,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const data = text ? JSON.parse(text) : null; // 204 has no body
if (!res.ok) throw new HttpError(res.status, data);
return data;
}
Cancelling
let controller;
async function search(q) {
controller?.abort(); // cancel the one in flight
controller = new AbortController();
try {
return await api(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
} catch (err) {
if (err.name === "AbortError") return null; // expected
throw err;
}
}
// a timeout, without writing the plumbing
fetch(url, { signal: AbortSignal.timeout(5000) });
encodeURIComponent on anything a person typed. A query containing & or # silently truncates the request otherwise.
Query strings
const url = new URL("/api/doors", location.origin);
url.searchParams.set("page", 2);
url.searchParams.set("q", userText); // encoded for you
await fetch(url);
CORS, in one paragraph
A request to a different origin is only readable by your page if that server sends Access-Control-Allow-Origin. The browser makes the request either way; it just refuses to give you the response.
You cannot fix a CORS error in your front-end code. It is fixed on the server, or by proxying the request through your own backend. A "no-cors" mode exists and gives you an opaque response you cannot read — it is not a workaround.
Uploading
const fd = new FormData(form);
fd.append("plan", file);
await fetch("/upload", { method: "POST", body: fd });
// the browser sets multipart/form-data WITH the boundary; setting it
// by hand omits the boundary and the server cannot parse the body
Loading, error, empty
Every request has four states and an interface has to show all of them: loading, success, empty success, and failure. The one that gets forgotten is empty success — the request worked and there are no results, which is not an error and must not look like one.
async function load() {
setState({ status: "loading" });
try {
const doors = await api("/api/doors");
setState({ status: doors.length ? "ready" : "empty", doors });
} catch (err) {
setState({ status: "error", message: err.message });
}
}