Real projects repeat the same fetch steps: send the request, check the status, parse JSON, handle failures. Put them in one helper.
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
export async function request(url, { method = "GET", data, timeout = 8000, headers = {} } = {}) {
const options = {
method,
headers: { Accept: "application/json", ...headers },
signal: AbortSignal.timeout(timeout)
};
if (data !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(data);
}
let response;
try {
response = await fetch(url, options);
} catch (error) {
if (error.name === "TimeoutError") throw new Error("The server took too long to answer.");
throw new Error("Could not reach the server.");
}
if (!response.ok) {
throw new HttpError(response.status, `Request failed (${response.status})`);
}
if (response.status === 204) return null; // no content
return response.json();
}
export const getJSON = (url, options) => request(url, options);
export const postJSON = (url, data, options) => request(url, { ...options, method: "POST", data });
Using it
import { getJSON, postJSON, HttpError } from "./api.js";
try {
const users = await getJSON("/api/users");
const created = await postJSON("/api/users", { name: "Maya" });
} catch (error) {
if (error instanceof HttpError && error.status === 404) {
showMessage("Not found.");
} else {
showMessage(error.message);
}
}
The same logic, runnable
The helper depends on a real server, so here is the central idea, response checking and JSON parsing, as a function that takes any fetch-like function. It lets us test every case without a network.
async function requestWith(fetchFn, url) {
let response;
try {
response = await fetchFn(url);
} catch {
throw new Error("Could not reach the server.");
}
if (!response.ok) {
throw new Error(`Request failed (${response.status})`);
}
return response.status === 204 ? null : response.json();
}
const fakes = {
ok: async () => new Response('{"name":"Maya"}'),
missing: async () => new Response("", { status: 404 }),
empty: async () => new Response(null, { status: 204 }),
offline: async () => { throw new TypeError("Failed to fetch"); }
};
async function main() {
for (const [name, fn] of Object.entries(fakes)) {
try {
console.log(name, "→", JSON.stringify(await requestWith(fn, "/api")));
} catch (error) {
console.log(name, "→", error.message);
}
}
}
main();
The loop prints ok → {"name":"Maya"}, missing → Request failed (404), empty → null and offline → Could not reach the server. Passing the function in (dependency injection) is also how you test code that calls the network.
Design choices explained
- One place for status checks, JSON parsing and timeouts.
- A custom error type (
HttpError) carries the status code, so callers can react to 404 or 401 specifically. See Custom Errors. - Friendly messages for network failures.
- A timeout by default, so a stalled server does not leave the interface waiting forever.
- It is a module (Modules), so any file can import it.
Libraries
fetch in a similar way. For many projects, a helper like this is all you need.