Making HTTP requests with fetch

Node.js · Practical · 10 min

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.

Check yourself

  1. fetch() to an endpoint returns HTTP 503. What happens?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/learn/getting-started/fetch