Talking to other services is part of nearly every backend, and fetch is now the standard way.
Node 22 has a global, web-compatible `fetch`, implemented by the undici HTTP client. No axios or node-fetch needed.
The most important rule: fetch only rejects on network failures, not on HTTP errors. A 404 or 500 resolves normally with res.ok === false. Always check res.ok (or res.status) before using the body.
Sending JSON means doing it yourself:
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(data),
});Reading the body: await res.json(), res.text(), res.arrayBuffer(), or res.body, a web ReadableStream you can iterate with for await or convert with Readable.fromWeb for piping to a file. A body can be read only once. If you don't need it, call await res.body?.cancel() so the connection can be reused.
There is no default timeout you control per request; pass signal: AbortSignal.timeout(5000). An aborted request rejects with an error named TimeoutError (from timeout) or AbortError.
Behind the scenes undici keeps a connection pool per origin with keep-alive, so repeated calls to the same API are cheap. For advanced tuning (pool size, proxies, custom TLS), install the undici package and pass a dispatcher.
Retries are your job: retry idempotent requests (GET, PUT, DELETE) on network errors and 502/503/504 with exponential backoff and a cap, and respect Retry-After on 429.
FormData, Blob, Headers, Request and Response are globals too, so multipart uploads and mocking responses work like in the browser.
Example
class HttpError extends Error {
constructor(res, body) {
super(`HTTP ${res.status} for ${res.url}`);
this.status = res.status;
this.body = body;
}
}
async function getJson(url, { timeoutMs = 5000, ...init } = {}) {
const res = await fetch(url, {
...init,
headers: { accept: "application/json", ...init.headers },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new HttpError(res, await res.text()); // fetch doesn't throw on 4xx/5xx
return res.json();
}
try {
const repo = await getJson("https://api.github.com/repos/nodejs/node");
console.log(repo.full_name, repo.stargazers_count);
} catch (err) {
if (err.name === "TimeoutError") console.error("timed out");
else console.error(err.message);
}Remember
- fetch resolves on HTTP errors — check
res.ok - Use
AbortSignal.timeout(ms)for deadlines - Bodies are streams, readable once; cancel unused ones
Watch out for
- Calling
res.json()on an error response and getting a confusing parse error instead of the status.