fetch & HTTP Clients

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. Almost every Node.js application is also an HTTP client: it calls a payment API, a search service, an LLM provider or another microservice of its own. Since Node.js 18 the answer to "which HTTP client should I use?" is, for most code, nothing you have to install: the global fetch() (the WHATWG Fetch standard, implemented by the bundled Undici library) is available in every supported release line. This page covers it end to end: requests, JSON and form bodies, streaming, deadlines and cancellation, retries with backoff and Retry-After, concurrency limits, the older node:http client with its agents and keep-alive, proxies and private certificate authorities in enterprise networks, and how to move an existing code base off axios. The server side of HTTP is in HTTP Server Fundamentals; TLS is in HTTPS, TLS & HTTP/2; cancellation primitives in Async Context & Cancellation. The running scenario is the Bookshelf application described in Introduction & Architecture; the examples call a small Bookshelf API that the page defines in a moment so that every listing runs offline.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

Global fetch(), Headers, Request, Response, FormData

No stability banner; documented as no longer experimental

Added in v17.5.0 and v16.15.0, unflagged in v18.0.0, "No longer experimental" since v21.0.0. Implemented by Undici, an HTTP/1.1 client written for Node.js; process.versions.undici tells you which version your binary bundles.

AbortSignal.timeout(), AbortSignal.any()

Stable

timeout() added in v17.3.0 and v16.14.0; any() in v20.3.0 and v18.17.0.

node:http: http.request(), http.get(), http.Agent

2 Stable

http.request() since v0.3.6. AbortSignal support since v15.3.0 and v14.17.0. The httpValidation option arrived in v26.3.0.

http.globalAgent

2 Stable

Keep-alive enabled with a 5 second timeout by default since v19.0.0.

new http.Agent({ proxyEnv })

1.1 Active development (Built-in Proxy Support)

Added in v24.5.0 and v22.21.0. agentKeepAliveTimeoutBuffer arrived in v24.7.0 and v22.20.0.

NODE_USE_ENV_PROXY=1, --use-env-proxy

1.1 Active development

Environment variable added in v24.0.0 and v22.21.0; flag in v24.5.0 and v22.21.0. The flag wins when both are set.

http.setGlobalProxyFromEnv()

Part of Built-in Proxy Support (1.1)

Added in v25.4.0 and v24.14.0. Not available on Node.js 22 (observed on 22.23.3).

NODE_USE_SYSTEM_CA=1, --use-system-ca

No separate banner

Environment variable added in v24.6.0 and v22.19.0; flag in v23.8.0 (other platforms than Windows and macOS since v23.9.0).

tls.getCACertificates(), tls.setDefaultCACertificates()

No separate banner

getCACertificates() in v23.10.0 and v22.15.0; setDefaultCACertificates() in v24.5.0 and v22.19.0.

NODE_EXTRA_CA_CERTS

No separate banner

Added in v7.3.0. Read only when the process starts.

The Undici version differs per release line, and so do some of its behaviours. In the runs for this page the bundled versions were:

Table 2. Bundled Undici per release line (observed with process.versions.undici)
Node.js line Release Bundled Undici

26

26.10.0

8.10.2

24

24.21.0

7.29.1

22

22.23.3

6.28.1

The test server used by the examples

To keep every example runnable offline, they call a tiny Bookshelf API started on port 0 (the operating system picks a free port). It exposes a JSON list, a POST endpoint, a 404, a slow endpoint, a flaky endpoint that fails twice with 503 and Retry-After: 1 and then succeeds, an NDJSON stream, an echo of the request content type and size, a redirect and a short unit of "work". The files in this page share one directory, with "type": "module" in package.json.

server.js: the Bookshelf API used by the examples
import { createServer } from 'node:http';
import { once } from 'node:events';

// A tiny Bookshelf API used by the client examples. Port 0 = the OS picks a free port.
export async function startServer() {
  let flaky = 0;
  const server = createServer(async (req, res) => {
    const url = new URL(req.url, 'http://localhost');
    if (url.pathname === '/books' && req.method === 'GET') {
      res.setHeader('content-type', 'application/json');
      res.end(JSON.stringify([{ id: 1, title: 'Dune' }, { id: 2, title: 'Emma' }]));
    } else if (url.pathname === '/books' && req.method === 'POST') {
      let body = '';
      for await (const chunk of req) body += chunk;
      res.writeHead(201, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ id: 3, ...JSON.parse(body) }));
    } else if (url.pathname === '/missing') {
      res.writeHead(404, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ error: 'not found' }));
    } else if (url.pathname === '/slow') {
      setTimeout(() => res.end('finally'), 2000).unref();
    } else if (url.pathname === '/flaky') {
      // Fails twice with 503 + Retry-After, then succeeds.
      if (flaky++ < 2) {
        res.writeHead(503, { 'retry-after': '1' });
        res.end('try later');
      } else {
        res.end('ok after ' + flaky + ' attempts');
      }
    } else if (url.pathname === '/lines') {
      res.setHeader('content-type', 'application/x-ndjson');
      for (let i = 1; i <= 5; i++) res.write(JSON.stringify({ n: i }) + '\n');
      res.end();
    } else if (url.pathname === '/old') {
      res.writeHead(301, { location: '/books' }).end();
    } else if (url.pathname === '/echo') {
      let length = 0;
      for await (const chunk of req) length += chunk.length;
      res.setHeader('content-type', 'application/json');
      res.end(JSON.stringify({ type: req.headers['content-type']?.split(';')[0], length }));
    } else if (url.pathname === '/work') {
      await new Promise((r) => setTimeout(r, 50));
      res.end('done');
    } else {
      res.writeHead(404).end();
    }
  });
  server.listen(0, '127.0.0.1');
  await once(server, 'listening');
  return { base: `http://127.0.0.1:${server.address().port}`, server, close: () => server.close() };
}

Global fetch: requests, JSON and errors

fetch(input, init) returns a promise for a Response as soon as the status line and headers have arrived; the body is still on its way. input is a string, a URL or a Request. init carries method, headers, body, signal, redirect and a few more. The response gives you status, ok (true for 200 to 299), headers, url, redirected and body readers: json(), text(), arrayBuffer(), blob() and the raw body, a web ReadableStream. Two behaviours surprise people coming from other clients, and both are in the next listing: a 404 or 500 is a successful fetch (you must check res.ok yourself), and a network failure rejects with a bare TypeError: fetch failed whose real reason is on err.cause.

Basic GET, JSON POST, the HTTP error trap and a network error
import { createServer } from 'node:http';
import { once } from 'node:events';
import { startServer } from './server.js';

const { base, close } = await startServer();

const res = await fetch(`${base}/books`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);          // fetch does NOT reject on 4xx/5xx
console.log(res.status, res.headers.get('content-type'), await res.json());

const created = await fetch(`${base}/books`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ title: 'Ulysses' }),
});
console.log(created.status, await created.json());

const missing = await fetch(`${base}/missing`);
console.log('a 404 is a normal response:', missing.status, missing.ok, await missing.json());

// A network failure rejects with TypeError; the real reason is in err.cause
const probe = createServer().listen(0, '127.0.0.1');          // find a free port, then free it again
await once(probe, 'listening');
const { port } = probe.address();
probe.close();
await once(probe, 'close');
try {
  await fetch(`http://127.0.0.1:${port}/`);
} catch (err) {
  console.log(err.name, '-', err.message, '| cause:', err.cause.code);
}
try { await fetch('/books'); } catch (err) { console.log(err.name, '-', err.message, '|', err.cause.code); }

console.log('bundled undici', process.versions.undici, 'on Node.js', process.version);
close();
Output (Node.js 26.10.0; the same on 24.21.0 and 22.23.3 except the Undici version)
200 application/json [ { id: 1, title: 'Dune' }, { id: 2, title: 'Emma' } ]
201 { id: 3, title: 'Ulysses' }
a 404 is a normal response: 404 false { error: 'not found' }
TypeError - fetch failed | cause: ECONNREFUSED
TypeError - Failed to parse URL from /books | ERR_INVALID_URL
bundled undici 8.10.2 on Node.js v26.10.0

The Learn article and the API page document the same fetch(); the Learn article additionally imports Pool and stream from the undici package, which means installing Undici from npm, because the copy bundled in Node.js is not importable (see Undici beyond fetch). Unlike a browser, Node.js has no page origin, so a relative URL such as /books is an error, and fetch has no baseURL option: build absolute URLs with new URL(path, base) (the migration section wraps this in a small client).

Table 3. What fetch does and does not reject on
Situation Result

The server answers 404, 429, 500 or any other status

The promise resolves. Check res.ok or res.status; read the body for the server’s message.

DNS failure, refused connection, TLS error, reset socket

Rejects with TypeError: fetch failed; err.cause carries the Node.js error (ECONNREFUSED, ENOTFOUND, UNABLE_TO_VERIFY_LEAF_SIGNATURE).

A relative URL, or a blocked "bad port" such as 1

Rejects with TypeError before any network traffic (observed: Failed to parse URL from /books, bad port).

The AbortSignal fires

Rejects with the signal’s reason: TimeoutError for AbortSignal.timeout(), AbortError for controller.abort() with no reason.

res.json() on a body that is not JSON

Rejects with SyntaxError; check the content-type first when the server may return HTML error pages.

Request bodies, Response helpers and redirects

fetch accepts several body types and sets the content-type header for the ones that carry their own type: a URLSearchParams becomes application/x-www-form-urlencoded, a FormData becomes multipart/form-data with a generated boundary, a string becomes text/plain;charset=UTF-8, and a Blob uses its own type. For JSON you serialise yourself and set the header, as in the previous listing. A body is single-use: reading it twice rejects with TypeError: Body is unusable, so clone() the response first when you need two readers. By default redirects are followed (redirect: 'follow'); 'manual' hands you the 3xx response and 'error' rejects.

URLSearchParams, FormData, Blob, clone() and redirect modes
import { startServer } from './server.js';

const { base, close } = await startServer();

// URLSearchParams -> application/x-www-form-urlencoded, set automatically
const form = await fetch(`${base}/echo`, { method: 'POST', body: new URLSearchParams({ q: 'dune', page: '1' }) });
console.log(await form.json());

// FormData with a Blob -> multipart/form-data with a generated boundary
const data = new FormData();
data.set('title', 'Dune');
data.set('cover', new Blob(['fake image bytes'], { type: 'image/png' }), 'cover.png');
console.log(await (await fetch(`${base}/echo`, { method: 'POST', body: data })).json());

// A string body is sent as text/plain;charset=UTF-8
console.log(await (await fetch(`${base}/echo`, { method: 'POST', body: 'hello' })).json());

// A body can be read once; clone() before reading when you need it twice
const res = await fetch(`${base}/books`);
const copy = res.clone();
console.log((await res.json()).length, (await copy.text()).length);
console.log(await res.json().catch((e) => `${e.name}: ${e.message}`));

// Redirects are followed unless you say otherwise
const followed = await fetch(`${base}/old`);
console.log('follow:', followed.status, followed.redirected, new URL(followed.url).pathname);
const manual = await fetch(`${base}/old`, { redirect: 'manual' });
console.log('manual:', manual.status, manual.headers.get('location'));
await fetch(`${base}/old`, { redirect: 'error' }).catch((e) => console.log('error: ', e.name, '-', e.cause.message));
close();
Output (Node.js 26.10.0)
{ type: 'application/x-www-form-urlencoded', length: 13 }
{ type: 'multipart/form-data', length: 277 }
{ type: 'text/plain', length: 5 }
2 49
TypeError: Body is unusable: Body has already been read
follow: 200 true /books
manual: 301 /books
error:  TypeError - unexpected redirect

Deadlines and cancellation

A fetch without a deadline can wait a very long time. Undici’s own socket-level defaults (per its documentation: 300 seconds for response headers and for each gap between body chunks, 10 seconds to connect) are far too generous for a request that a user is waiting on, so give every outbound call an AbortSignal. AbortSignal.timeout(ms) is a signal that aborts itself after the delay with a TimeoutError, and the deadline covers the whole exchange: in a test where the server sent the headers and then stalled, res.text() still rejected with TimeoutError after the deadline. To let the caller cancel as well (a user navigating away, a parent request being aborted), combine signals with AbortSignal.any(); the reason of whichever signal fired first is what the promise rejects with. The same signals work with fs, timers, events and child processes, as described in Async Context & Cancellation.

A deadline, and a deadline combined with a cancel signal
import { startServer } from './server.js';

const { base, close } = await startServer();

// A deadline for the whole exchange, including reading the body
try {
  const res = await fetch(`${base}/slow`, { signal: AbortSignal.timeout(300) });
  console.log(await res.text());
} catch (err) {
  console.log(err.name, '-', err.message);       // TimeoutError
}

// A deadline plus a cancel button that the caller controls
const user = new AbortController();
setTimeout(() => user.abort(), 100);
try {
  await fetch(`${base}/slow`, { signal: AbortSignal.any([user.signal, AbortSignal.timeout(5000)]) });
} catch (err) {
  console.log(err.name, '-', err.message);       // AbortError: the user's signal fired first
}
close();
Output (Node.js 26.10.0; identical on 24 and 22)
TimeoutError - The operation was aborted due to timeout
AbortError - This operation was aborted

Always distinguish the two reasons in catch: a TimeoutError is usually worth a retry or a clear "upstream is slow" error, while an AbortError is somebody else’s decision and should not be retried or logged as a failure.

Streaming bodies

res.body is a web ReadableStream of Uint8Array chunks. That is what makes fetch suitable for large downloads, newline-delimited JSON, server-sent events and LLM token streams: you process data as it arrives and memory stays flat. A web stream is async-iterable, so for await works, and pipeThrough(new TextDecoderStream()) turns bytes into text without splitting multi-byte characters. To connect it to the classic node:stream world (files, zlib, pipeline()), use Readable.fromWeb(res.body); to send a streaming request body, pass a Readable, an async generator wrapped in Readable.from() or a ReadableStream, and set duplex: 'half'. Without duplex the call rejects (observed: TypeError: RequestInit: duplex option is required when sending a body). Conversions are covered in Web Streams & Interop and backpressure in Streams.

If you do not need the body, say so. An unread body keeps the connection busy, and a connection that is neither drained nor cancelled cannot return to the pool; call await res.body.cancel() (or read it) on every response you stop caring about.

NDJSON line by line, download to a file with backpressure, streaming upload, and cancelling an unread body
import { startServer } from './server.js';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const { base, close } = await startServer();

// Read an NDJSON response line by line, as the bytes arrive
const res = await fetch(`${base}/lines`);
const lines = res.body.pipeThrough(new TextDecoderStream());
let pending = '';
for await (const text of lines) {
  pending += text;
  const parts = pending.split('\n');
  pending = parts.pop();                       // keep an incomplete last line
  for (const line of parts) console.log(JSON.parse(line));
}

// Save a response straight to disk with backpressure
const dest = join(tmpdir(), 'books.json');
const res2 = await fetch(`${base}/books`);
await pipeline(Readable.fromWeb(res2.body), createWriteStream(dest));
console.log('saved', dest);

// Stream a request body from an async generator (needs duplex: 'half')
async function* chunks() {
  yield '{"title":';
  yield '"Streamed"}';
}
const up = await fetch(`${base}/books`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: Readable.from(chunks()),
  duplex: 'half',
});
console.log(up.status, await up.json());

// Not reading the body: cancel it so the connection can be reused
const unread = await fetch(`${base}/books`);
console.log('status only:', unread.status);
await unread.body.cancel();
close();
Output (Node.js 26.10.0; the temporary directory differs per machine)
{ n: 1 }
{ n: 2 }
{ n: 3 }
{ n: 4 }
{ n: 5 }
saved <tmpdir>/books.json
201 { id: 3, title: 'Streamed' }
status only: 200

Retries with backoff and Retry-After

Networks and upstream services fail transiently: a connection reset, a 503 during a deploy, a 429 when you exceed a rate limit. A small retry policy turns many of those into non-events, but a careless one multiplies load on a service that is already struggling. The rules in the helper below are the usual ones, and each is a line in the code:

Table 4. Retry policy implemented below
Rule Why

Retry only idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE)

Repeating a POST can create two orders. Retry it only when the API supports an idempotency key and you send one.

Retry network errors and the statuses 408, 429, 500, 502, 503, 504

These usually mean "try again"; a 400, 401 or 404 will not get better.

Exponential backoff with full jitter

The delay is a random value between zero and base * 2^attempt, capped; the randomness keeps many clients from retrying in lockstep.

Honour Retry-After

The header is either a number of seconds or an HTTP date (RFC 9110). When the server says when to come back, obey it instead of guessing; the cap still protects you from an absurd value.

Bound the attempts and respect the caller’s signal

The sleep uses timers/promises with the same AbortSignal, so cancelling or timing out the call stops the retry loop immediately.

Cancel the failed response body before waiting

An unread body would hold its connection for the whole backoff.

retry.js: fetchWithRetry() and retryAfterMs()
import { setTimeout as sleep } from 'node:timers/promises';

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);

// Retry-After is either delta-seconds or an HTTP-date.
export function retryAfterMs(value, now = Date.now()) {
  if (!value) return null;
  if (/^\d+$/.test(value)) return Number(value) * 1000;
  const at = Date.parse(value);
  return Number.isNaN(at) ? null : Math.max(0, at - now);
}

export async function fetchWithRetry(url, init = {}, { retries = 3, baseMs = 200, capMs = 10_000 } = {}) {
  const method = (init.method ?? 'GET').toUpperCase();
  const canRetry = IDEMPOTENT.has(method);               // never blindly retry POST
  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      res = await fetch(url, init);
    } catch (err) {
      if (init.signal?.aborted || !canRetry || attempt >= retries) throw err;
      await sleep(Math.random() * Math.min(capMs, baseMs * 2 ** attempt), undefined, { signal: init.signal });
      continue;                                           // network error: back off and retry
    }
    if (!RETRYABLE_STATUS.has(res.status) || !canRetry || attempt >= retries) return res;
    const hinted = retryAfterMs(res.headers.get('retry-after'));
    const backoff = Math.random() * Math.min(capMs, baseMs * 2 ** attempt);   // "full jitter"
    await res.body?.cancel();                             // release the connection before waiting
    await sleep(Math.min(capMs, hinted ?? backoff), undefined, { signal: init.signal });
  }
}
Retrying the flaky endpoint
import { startServer } from './server.js';
import { fetchWithRetry, retryAfterMs } from './retry.js';

const { base, close } = await startServer();

const t0 = Date.now();
const res = await fetchWithRetry(`${base}/flaky`, { signal: AbortSignal.timeout(10_000) });
console.log(res.status, await res.text(), `after ${Math.round((Date.now() - t0) / 100) / 10}s`);

console.log(retryAfterMs('120'), retryAfterMs(new Date(Date.now() + 5000).toUTCString()) > 3000, retryAfterMs('soon'));
close();
Output (Node.js 26.10.0; two 503 responses with Retry-After: 1, then success)
200 ok after 3 attempts after 2s
120000 true null

The signal passed to fetchWithRetry() is shared by all attempts, so AbortSignal.timeout(10_000) above is a budget for the whole operation, not per attempt. If you want a per-attempt limit as well, build it inside the loop and combine both with AbortSignal.any(). For production traffic consider a circuit breaker so that a dead dependency fails fast instead of absorbing every request’s full retry budget.

Concurrency limits

Promise.all(items.map(fetchOne)) starts every request at once. With 16 items that is 16 sockets (measured in the next sections: global fetch opened all 16 connections); with 10,000 items it exhausts file descriptors, trips the remote rate limit or overwhelms your own event loop. Bound the fan-out. The helper below keeps at most limit calls in flight with a shared iterator and limit worker loops, and preserves the input order of the results. Libraries such as p-limit do the same with more features, but the pattern is short enough to own.

mapLimit.js: a bounded Promise.all()
// Run fn over items with at most `limit` calls in flight; results keep input order.
export async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  const entries = items.entries();                        // one shared iterator
  async function worker() {
    for (const [i, item] of entries) results[i] = await fn(item, i);
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}
Twenty requests, at most four at a time
import { startServer } from './server.js';
import { mapLimit } from './mapLimit.js';

const { base, close } = await startServer();

let inFlight = 0;
let peak = 0;
const ids = Array.from({ length: 20 }, (_, i) => i);
const out = await mapLimit(ids, 4, async (i) => {
  inFlight++;
  peak = Math.max(peak, inFlight);
  const res = await fetch(`${base}/work`);
  const text = await res.text();                      // always consume the body
  inFlight--;
  return `${i}:${text}`;
});
console.log(out.length, out[0], out.at(-1), '| peak in flight =', peak);
close();
Output (Node.js 26.10.0; identical on 24 and 22)
20 0:done 19:done | peak in flight = 4

An application-level limit and a connection-level limit are different tools. mapLimit() bounds how much work you start; an agent’s maxSockets (next section) or an Undici dispatcher’s connections bounds how many sockets are opened per origin. Use the first to protect yourself and the remote API, and the second to keep the connection count predictable.

node:http clients: http.request(), agents and keep-alive

node:http is the original client, and it is still what https, many SDKs and older libraries use. http.request(url, options, callback) returns a ClientRequest (a writable stream: you must call req.end()), and the callback receives an IncomingMessage (a readable stream). http.get() is the same call with the method fixed to GET and end() called for you. It is lower level than fetch: no JSON helpers, no automatic redirect following, no promise. Errors arrive as the request’s 'error' event, a stalled socket as 'timeout' (and the request must be destroyed by hand), and cancellation through the signal option. The wrapper below turns a request into a promise using events.once(), whose rejection on 'error' removes the boilerplate.

httpclient.js: a promise wrapper around http.request()
import http from 'node:http';
import { once } from 'node:events';

// Minimal promise wrapper around http.request(): resolves with { status, headers, body }.
export async function request(url, { method = 'GET', headers, body, agent, signal, timeout } = {}) {
  const req = http.request(url, { method, headers, agent, signal, timeout });
  req.on('timeout', () => req.destroy(new Error(`socket idle for ${timeout} ms`)));
  req.end(body);
  const [res] = await once(req, 'response');            // 'error' rejects once() automatically
  const chunks = [];
  for await (const chunk of res) chunks.push(chunk);     // IncomingMessage is a Readable
  return { status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString() };
}

An http.Agent decides what happens to a socket after a request: pool it for the next request to the same host and port, or close it. Opening a TCP connection (and a TLS handshake for https) costs round trips, so reuse matters for chatty clients. The facts that matter in practice:

Table 5. http.Agent behaviour and options
Option or object Meaning

http.globalAgent

Used when you pass no agent. Since v19.0.0 it has keep-alive enabled and a 5 second timeout; before that it closed the socket after every request.

new http.Agent()

A fresh agent has keepAlive: false by default: one connection per request, then close. Pass keepAlive: true to pool connections.

agent: false

A throwaway agent with default options for one request: no reuse.

keepAlive, keepAliveMsecs

Keep idle sockets for reuse; keepAliveMsecs is the initial delay of TCP keep-alive probes (default 1000).

agentKeepAliveTimeoutBuffer

Milliseconds subtracted from the server’s keep-alive: timeout= hint when deciding when to drop an idle socket (default 1000, added in v24.7.0 and v22.20.0), so the client closes first and does not send on a socket the server is about to close.

maxSockets, maxTotalSockets

Per-host and overall caps on open sockets (default Infinity); extra requests wait in agent.requests.

maxFreeSockets

Idle sockets kept per host when keepAlive is on (default 256).

scheduling

'lifo' (default since v15.6.0) reuses the most recently used socket; 'fifo' the least recently used.

timeout

Socket timeout in milliseconds, applied when the socket is created.

proxyEnv

Per-agent proxy settings (see Proxies).

The next program counts server-side TCP connections for five sequential requests under each configuration, shows that maxSockets caps parallel connections, and exercises both timeout styles.

Agents and keep-alive measured from the server’s connection counter
import http from 'node:http';
import { startServer } from './server.js';
import { request } from './httpclient.js';
const { base, server, close } = await startServer();
let sockets = 0;
server.on('connection', () => sockets++);

const r = await request(`${base}/books`, { headers: { accept: 'application/json' } });
console.log(r.status, r.headers['content-type'], JSON.parse(r.body).length, 'books');
console.log('globalAgent keepAlive:', http.globalAgent.keepAlive, '| maxSockets:', http.globalAgent.maxSockets);
http.globalAgent.destroy();                              // forget the pooled socket for a clean count

async function sequential(label, agent) {
  sockets = 0;
  for (let i = 0; i < 5; i++) await request(`${base}/books`, { agent });
  console.log(label.padEnd(26), 'TCP connections for 5 requests:', sockets);
}
await sequential('default (globalAgent)', undefined);
await sequential('new Agent()', new http.Agent());
await sequential('agent: false', false);
const pooled = new http.Agent({ keepAlive: true, maxSockets: 2 });
await sequential('new Agent({ keepAlive })', pooled);
pooled.destroy();

// maxSockets bounds concurrent connections per origin; the rest wait in agent.requests
const limited = new http.Agent({ keepAlive: true, maxSockets: 2 });
sockets = 0;
await Promise.all(Array.from({ length: 8 }, () => request(`${base}/work`, { agent: limited })));
console.log('8 concurrent, maxSockets 2 -> connections opened:', sockets);

try { await request(`${base}/slow`, { timeout: 200 }); } catch (e) { console.log(e.message); }
try { await request(`${base}/slow`, { signal: AbortSignal.timeout(200) }); } catch (e) { console.log(e.name); }

limited.destroy();
http.globalAgent.destroy();
close();
Output (Node.js 26.10.0; identical on 24 and 22)
200 application/json 2 books
globalAgent keepAlive: true | maxSockets: Infinity
default (globalAgent)      TCP connections for 5 requests: 1
new Agent()                TCP connections for 5 requests: 5
agent: false               TCP connections for 5 requests: 5
new Agent({ keepAlive })   TCP connections for 5 requests: 1
8 concurrent, maxSockets 2 -> connections opened: 2
socket idle for 200 ms
AbortError

Three practical consequences. First, if you create your own Agent, set keepAlive: true and destroy() it on shutdown; unused pooled sockets are unreferenced, so they do not keep the process alive, but they do consume file descriptors. Second, pooled connections can be closed by the server while idle, so a request can occasionally fail on a reused socket; that is another reason to retry idempotent calls. Third, the socket timeout option is an idle timeout (no bytes), not a deadline: use signal: AbortSignal.timeout(ms) for a total deadline. The server side of these limits is in Server Timeouts, Limits & Hardening.

Undici beyond fetch: dispatchers and the version trap

Global fetch has no connection options of its own, because Undici’s connection management lives in a dispatcher. A dispatcher decides how many connections are opened, their timeouts and TLS settings, and where proxy traffic goes. Node.js gives you two ways to customise it, both from the API page for fetch: pass { dispatcher } in the options of one call, or install a global dispatcher with setGlobalDispatcher() from the undici package. The bundled Undici is internal, so you install the npm package (npm install undici) to get Agent, Pool, request(), MockAgent and friends.

The catch, observed while preparing this page: a dispatcher is only guaranteed to work with the fetch of the same Undici version. With the current npm release (8.11.2 at the time of writing) a dispatcher passed to the global fetch worked on Node.js 26 (bundled 8.10.2), but on Node.js 24 (bundled 7.29.1) and Node.js 22 (bundled 6.28.1) the call rejected with InvalidArgumentError: invalid onRequestStart method as the cause. The robust pattern is to import fetch from the same package as the dispatcher, as the example does, or to pin the undici major that matches process.versions.undici of your oldest supported Node.js line.

Limiting connections with an Undici Agent, and Undici’s lower-level request()
import { Agent, fetch as undiciFetch, request } from 'undici';
import { startServer } from './server.js';
const { base, server, close } = await startServer();
let sockets = 0;
server.on('connection', () => sockets++);

async function burst(label, doFetch, init) {
  sockets = 0;
  await Promise.all(Array.from({ length: 16 }, () => doFetch(`${base}/work`, init).then((r) => r.text())));
  console.log(label.padEnd(40), 'connections opened:', sockets);
}
await burst('global fetch, defaults', fetch);

// Use the SAME package for the dispatcher and for fetch: the Agent must match the fetch implementation
const dispatcher = new Agent({ connections: 4, connect: { timeout: 2000 } });
await burst('undici fetch + Agent({ connections: 4 })', undiciFetch, { dispatcher });

const { statusCode, headers, body } = await request(`${base}/books`, { dispatcher });
console.log(statusCode, headers['content-type'], (await body.json()).length, 'books');
await dispatcher.close();
close();
Output (Node.js 26.10.0, 24.21.0 and 22.23.3 with undici 8.11.2 installed)
global fetch, defaults                   connections opened: 16
undici fetch + Agent({ connections: 4 }) connections opened: 4
200 application/json 2 books

request() from the package skips the Response object and the Fetch-standard layer for raw speed and control. It requires you to consume or dump() the body of every response, which is the same discipline as with fetch. Undici’s fetch is documented by Node.js as an HTTP/1.1 client; Undici’s Client has an opt-in allowH2 option for HTTP/2, and for HTTP/2 in general see HTTPS, TLS & HTTP/2.

Proxies

Corporate networks often allow outbound traffic only through an HTTP(S) proxy, announced to tools through the conventional environment variables HTTP_PROXY, HTTPS_PROXY and NO_PROXY (and their lowercase forms). Historically Node.js ignored them, and every library implemented proxy support on its own: https-proxy-agent for http, an Undici ProxyAgent or EnvHttpProxyAgent for fetch. Node.js now has built-in support, Stability 1.1 (Active development), switched on explicitly:

Table 6. Ways to enable built-in proxy support
Mechanism What it does

NODE_USE_ENV_PROXY=1 (environment variable)

Reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY at startup and routes fetch(), http and https requests through the proxy by default.

node --use-env-proxy app.js

The same, as a flag; the flag takes precedence if both are set. The variables can come from --env-file.

http.setGlobalProxyFromEnv([proxyEnv])

Turns support on at runtime (v25.4.0, v24.14.0), reading process.env or the object you pass, and returns a function that restores the previous global agent and dispatcher. Call it before any request is in flight.

new http.Agent({ proxyEnv }) or new https.Agent({ proxyEnv })

Proxy settings for one agent only (v24.5.0, v22.21.0): proxyEnv is an object with HTTP_PROXY, HTTPS_PROXY, NO_PROXY (and lowercase forms, which win if both are present).

A proxy URL may be http:// or https://, with optional user:password@ credentials. NO_PROXY takes a comma-separated list: for every host, an exact host name, a .suffix or .suffix domain match, an exact IP, an IP range such as 192.168.1.1-192.168.1.100, or host:port. Overriding http.globalAgent or https.globalAgent by hand affects http/https requests but does not affect fetch(); for fetch use the environment variable, setGlobalProxyFromEnv() or a dispatcher.

The next two programs use a small forward proxy defined below. It maps every host name to 127.0.0.1, so a request for the invented name books.internal.test can only succeed if it really goes through the proxy.

proxy.js and run-proxy.mjs: a forward proxy for experiments (not for production)
import http from 'node:http';
import net from 'node:net';
import { once } from 'node:events';

// A tiny forward proxy for experiments. It handles plain HTTP forwarding and CONNECT tunnels,
// and maps every host name to 127.0.0.1 so that invented names such as books.internal.test work.
export async function startProxy() {
  const proxy = http.createServer((req, res) => {
    console.log('[proxy]', req.method, req.url);                // absolute-form target: GET http://host:port/path
    const target = new URL(req.url);
    const upstream = http.request(
      { host: '127.0.0.1', port: target.port, path: target.pathname + target.search, method: req.method, headers: req.headers },
      (up) => { res.writeHead(up.statusCode, up.headers); up.pipe(res); },
    );
    req.pipe(upstream);
  });
  proxy.on('connect', (req, client, head) => {
    console.log('[proxy] CONNECT', req.url);                    // used for tunnels, e.g. HTTPS through a proxy
    const upstream = net.connect(Number(req.url.split(':')[1]), '127.0.0.1', () => {
      client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
      upstream.write(head);
      upstream.pipe(client);
      client.pipe(upstream);
    });
    upstream.on('error', () => client.destroy());
    client.on('error', () => upstream.destroy());
  });
  proxy.listen(0, '127.0.0.1');
  await once(proxy, 'listening');
  return { url: `http://127.0.0.1:${proxy.address().port}`, close: () => proxy.close() };
}

// run-proxy.mjs
import { startProxy } from './proxy.js';

const proxy = await startProxy();
console.log(proxy.url);       // the first line is the proxy URL; the process keeps running
e09-proxy.mjs: the same request through fetch and http.get, with and without proxy support
import http from 'node:http';
import { startServer } from './server.js';
import { request } from './httpclient.js';

const { base, close } = await startServer();
const fake = `http://books.internal.test:${new URL(base).port}/books`;   // resolvable only through the proxy
console.log('NODE_USE_ENV_PROXY =', process.env.NODE_USE_ENV_PROXY, '| HTTP_PROXY =', process.env.HTTP_PROXY);

for (const [label, call] of [
  ['fetch', async () => (await fetch(fake)).status],
  ['http.get', async () => (await request(fake)).status],
]) {
  try { console.log(label.padEnd(9), '->', await call()); }
  catch (e) { console.log(label.padEnd(9), '-> failed:', e.cause?.code ?? e.code ?? e.message); }
}
http.globalAgent.destroy();
close();
Running it three ways (the proxy URL is printed by run-proxy.mjs)
node run-proxy.mjs &                       # prints http://127.0.0.1:<port>
export HTTP_PROXY=http://127.0.0.1:<port>

node e09-proxy.mjs                         # proxy variables are ignored: ENOTFOUND
NODE_USE_ENV_PROXY=1 node e09-proxy.mjs    # both requests go through the proxy
NO_PROXY=books.internal.test node --use-env-proxy e09-proxy.mjs   # bypassed again: ENOTFOUND
Output (Node.js 26.10.0)
NODE_USE_ENV_PROXY = undefined | HTTP_PROXY = http://127.0.0.1:<port>
fetch     -> failed: ENOTFOUND
http.get  -> failed: ENOTFOUND
NODE_USE_ENV_PROXY = 1 | HTTP_PROXY = http://127.0.0.1:<port>
fetch     -> 200
http.get  -> 200
NODE_USE_ENV_PROXY = undefined | HTTP_PROXY = http://127.0.0.1:<port>
fetch     -> failed: ENOTFOUND
http.get  -> failed: ENOTFOUND

Observed differences between lines: on Node.js 22.23.3 the first fetch through the environment proxy printed [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental (the same Undici class that backs the feature), which 24.21.0 and 26.10.0 did not; and the proxy log showed the fetch request arriving as a CONNECT tunnel on 22 and 24 but as a plain forwarded GET on 26. Both reach the target, so treat this as a reminder that Stability 1.1 means details can shift.

For per-agent and runtime configuration there are two more entry points, shown next. http.setGlobalProxyFromEnv() does not exist on Node.js 22, so the program checks for it.

A proxy for one agent, and enabling support at runtime
import http from 'node:http';
import { startServer } from './server.js';

const { base, close } = await startServer();
const fake = `http://books.internal.test:${new URL(base).port}/books`;
const proxyUrl = process.env.PROXY_URL;                   // set by the shell for this experiment

// 1. One request through a specific proxy: an agent with proxyEnv (v22.21.0 / v24.5.0 and later)
const agent = new http.Agent({ proxyEnv: { HTTP_PROXY: proxyUrl } });
const status = await new Promise((resolve, reject) =>
  http.get(fake, { agent }, (res) => { res.resume(); resolve(res.statusCode); }).on('error', reject));
console.log('http.get with proxyEnv agent ->', status);
agent.destroy();

// 2. Switch proxy support on for the whole process at runtime (v24.14.0 / v25.4.0 and later)
if (typeof http.setGlobalProxyFromEnv === 'function') {
  const restore = http.setGlobalProxyFromEnv({ http_proxy: proxyUrl });
  console.log('fetch after setGlobalProxyFromEnv ->', (await fetch(fake)).status);
  restore();                                               // back to direct connections
  await fetch(fake).catch((e) => console.log('after restore() ->', e.cause.code));
} else {
  console.log('http.setGlobalProxyFromEnv is not available on', process.version);
}
http.globalAgent.destroy();
close();
Output (Node.js 26.10.0 and 24.21.0; on 22.23.3 the second step prints "http.setGlobalProxyFromEnv is not available on v22.23.3")
http.get with proxyEnv agent -> 200
fetch after setGlobalProxyFromEnv -> 200
after restore() -> ENOTFOUND

Security notes that the documentation stresses: proxy support is for reaching external networks through proxies that your organisation trusts and authorises, not for hiding traffic. A proxy sees connection metadata, and sees request and response contents for plain http or when it terminates TLS. Credentials inside HTTPS_PROXY end up in the environment of the process and of its children, so prefer a secret manager and programmatic configuration over committing them to an env file.

Enterprise certificate authorities

Inside a company, internal services are often signed by a private CA that is installed in the operating system’s trust store, which Node.js does not consult by default: it uses its own bundle of Mozilla roots. The symptom is an error such as self signed certificate in certificate chain or UNABLE_TO_VERIFY_LEAF_SIGNATURE. There are four correct fixes, and one tempting wrong one.

Table 7. Ways to trust a private CA
Mechanism Scope Versions

NODE_USE_SYSTEM_CA=1 or --use-system-ca

Whole process: bundled roots plus the system store (Windows certificate store, macOS keychain, on Linux the OpenSSL default locations)

v22.19.0 and v24.6.0 for the variable; the flag since v23.8.0

NODE_EXTRA_CA_CERTS=/path/ca.pem

Whole process: bundled roots plus the PEM file; read once at startup; ignored when node runs as setuid root or with Linux file capabilities

v7.3.0

tls.setDefaultCACertificates([…​]) (with tls.getCACertificates('default' | 'system' | 'bundled' | 'extra') for the lists)

Whole thread, at runtime; call it before the first TLS connection

v22.19.0 and v24.5.0 (getCACertificates since v22.15.0 and v23.10.0)

The ca option of https.request(), tls.connect(), or an Undici dispatcher’s connect: { ca }

One request or one dispatcher; replaces the default roots instead of adding to them

All lines

The wrong fix is NODE_TLS_REJECT_UNAUTHORIZED=0 (or rejectUnauthorized: false), which turns certificate validation off; the CLI documentation says plainly that it makes TLS insecure and is strongly discouraged. It also silences exactly the attacker who would present a forged certificate.

To reproduce the problem and every fix offline, create a private CA and a server certificate with openssl (these are throwaway test files), and a small HTTPS server.

Creating a test CA and a server certificate for localhost
mkdir -p certs && cd certs
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -days 30 -subj "/CN=Bookshelf Test CA"
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=localhost"
printf "subjectAltName=DNS:localhost,IP:127.0.0.1\n" > san.ext
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.pem -days 30 -extfile san.ext
cd ..
tlsserver.js: an HTTPS server with a certificate issued by the private CA
import https from 'node:https';
import { readFileSync } from 'node:fs';
import { once } from 'node:events';

// HTTPS server whose certificate was issued by a private CA (certs/ca.pem)
export async function startTlsServer() {
  const server = https.createServer(
    { key: readFileSync('certs/server.key'), cert: readFileSync('certs/server.pem') },
    (req, res) => res.end('secure hello'),
  );
  server.listen(0, '127.0.0.1');
  await once(server, 'listening');
  return { url: `https://localhost:${server.address().port}/`, close: () => server.close() };
}
Failing, then fixing, TLS validation for fetch and https.get
import https from 'node:https';
import tls from 'node:tls';
import { readFileSync } from 'node:fs';
import { Agent, fetch as undiciFetch } from 'undici';
import { startTlsServer } from './tlsserver.js';

const { url, close } = await startTlsServer();
const ca = readFileSync('certs/ca.pem', 'utf8');
const get = (opts) => new Promise((resolve, reject) =>
  https.get(url, opts, (res) => { res.resume(); resolve(res.statusCode); }).on('error', reject));

console.log('NODE_EXTRA_CA_CERTS =', process.env.NODE_EXTRA_CA_CERTS);
try { console.log('fetch    ->', (await fetch(url)).status); }
catch (e) { console.log('fetch    -> failed:', e.cause.code, '-', e.cause.message); }
try { console.log('https.get ->', await get({})); }
catch (e) { console.log('https.get -> failed:', e.code); }

// Per-request trust: https.request() takes a ca option (it REPLACES the default roots)
if (!process.env.NODE_EXTRA_CA_CERTS) {
  console.log('https.get with ca ->', await get({ ca }));
  // The same idea for fetch: a dispatcher whose TLS options carry the CA
  const dispatcher = new Agent({ connect: { ca } });
  console.log('undici fetch with ca ->', (await undiciFetch(url, { dispatcher })).status);
  await dispatcher.close();
  // Process-wide: add the CA to the default list (v22.19.0 / v24.5.0 and later)
  tls.setDefaultCACertificates([...tls.getCACertificates('default'), ca]);
  console.log('fetch after setDefaultCACertificates ->', (await fetch(url)).status);
}
https.globalAgent.destroy();
close();
Running it without and with NODE_EXTRA_CA_CERTS
node e11-tls.mjs
NODE_EXTRA_CA_CERTS=certs/ca.pem node e11-tls.mjs
Output (Node.js 26.10.0; 24.21.0 is identical, 22.23.3 omits the --use-system-ca hint from the message)
NODE_EXTRA_CA_CERTS = undefined
fetch    -> failed: UNABLE_TO_VERIFY_LEAF_SIGNATURE - unable to verify the first certificate; if the root CA is installed locally, try running Node.js with --use-system-ca
https.get -> failed: UNABLE_TO_VERIFY_LEAF_SIGNATURE
https.get with ca -> 200
undici fetch with ca -> 200
fetch after setDefaultCACertificates -> 200
NODE_EXTRA_CA_CERTS = certs/ca.pem
fetch    -> 200
https.get -> 200

The --use-system-ca path was not exercised here, because it depends on the trust store of the machine running the program; the Learn article and the CLI page describe which locations are read on each platform. In containers, mount the company CA bundle and point NODE_EXTRA_CA_CERTS at it; see Production Deployment and Security Best Practices.

Migrating from axios

axios was the default HTTP client for a decade because browsers and old Node.js lacked a good one. Today fetch covers the common cases without a dependency, and the Node.js project ships a codemod for the mechanical part of the move: npx codemod @nodejs/axios-to-whatwg-fetch (documented in the Node.js Learn "Userland Migrations" series). It rewrites axios.request(), get(), delete(), head(), options(), post(), put(), patch() and the postForm(), putForm() and patchForm() helpers, in ESM imports, aliased imports, CommonJS require() and dynamic import(), and removes axios and @types/axios from package.json once every call site is converted. It was not run for this page; its documented behaviour is what the following paragraphs describe.

The codemod keeps response.data working by wrapping each call as fetch(url).then(async (res) ⇒ Object.assign(res, { data: await res.json() })) and appends .catch(() ⇒ null), so a failed request resolves to null instead of rejecting. Two more points from the documentation matter for review: fetch does not reject on 4xx and 5xx statuses, so code that relied on axios rejecting needs manual attention, and if any call in a file uses a configuration option that has no direct equivalent, the whole file is left untouched with a warning. The options on that list include timeout, signal, validateStatus, maxRedirects, maxContentLength, httpAgent, httpsAgent, transformRequest, transformResponse, paramsSerializer, withCredentials, cancelToken, socketPath, decompress and beforeRedirect; interceptors, cancel tokens and axios.create() instances are outside its scope. That is most real-world axios code, which is why the sensible approach is to migrate by hand around a small client of your own, not to expect the codemod to finish the job.

Table 8. Where axios and fetch differ
Concern axios fetch (and what to write)

Status errors

Rejects on non-2xx (AxiosError, error.response.status, error.response.data).

Resolves. Check res.ok; throw your own HttpError with status and parsed body.

Response body

Parsed into response.data.

Call res.json() or res.text(); a Response body is single-use.

Request JSON

Object data is serialised and content-type set.

JSON.stringify() the body and set content-type: application/json.

Query string

params option, paramsSerializer.

new URL() plus url.searchParams.set(), or new URLSearchParams(obj).

Base URL

baseURL on the instance.

new URL(path, base); note that a leading slash in path replaces any path in base.

Timeout

timeout option (ECONNABORTED).

signal: AbortSignal.timeout(ms), rejecting with TimeoutError.

Cancellation

signal (and the deprecated cancel token).

signal with AbortController, AbortSignal.any().

Interceptors

interceptors.request and interceptors.response.

A wrapper function around fetch, or an Undici interceptor on a dispatcher.

Connection pooling and proxy

httpAgent, httpsAgent, proxy option.

Undici dispatcher, NODE_USE_ENV_PROXY, proxyEnv.

Upload and download progress

onUploadProgress, onDownloadProgress.

Stream the body and count bytes yourself.

Automatic retries

Not built in (plugins).

Your own helper, as in Retries.

The wrapper below is a replacement for the axios.create() pattern in about thirty lines: base URL, default headers, query parameters, JSON in and out, a per-call deadline that can be combined with a caller signal, and an HttpError that carries the status and parsed body like error.response did. The program then runs the same calls through axios (installed only in the scratch project) and through the wrapper to compare results.

client.js: an axios.create() replacement built on fetch
export class HttpError extends Error {
  constructor(res, body) {
    super(`HTTP ${res.status} ${res.statusText} for ${res.url}`);
    this.name = 'HttpError';
    this.status = res.status;
    this.body = body;                 // parsed JSON or text, like axios's error.response.data
    this.headers = res.headers;
  }
}

// A small axios.create() replacement: base URL, default headers, query params,
// JSON in/out, a per-request timeout and throwing on non-2xx statuses.
export function createClient({ baseUrl, headers = {}, timeoutMs = 10_000 } = {}) {
  const root = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
  async function send(method, path, { params, json, signal, headers: extra } = {}) {
    const url = new URL(path.replace(/^\//, ''), root);            // 'books', not '/books': a leading slash resets the path
    for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, String(v));
    const res = await fetch(url, {
      method,
      headers: { accept: 'application/json', ...(json !== undefined && { 'content-type': 'application/json' }), ...headers, ...extra },
      body: json === undefined ? undefined : JSON.stringify(json),
      signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
    });
    const text = await res.text();
    const data = text && res.headers.get('content-type')?.includes('json') ? JSON.parse(text) : text;
    if (!res.ok) throw new HttpError(res, data);                   // axios rejects here; fetch does not
    return { status: res.status, headers: res.headers, data };
  }
  return {
    get: (path, opts) => send('GET', path, opts),
    post: (path, json, opts) => send('POST', path, { ...opts, json }),
    put: (path, json, opts) => send('PUT', path, { ...opts, json }),
    delete: (path, opts) => send('DELETE', path, opts),
  };
}
The same calls through axios and through the fetch client
import axios from 'axios';
import { startServer } from './server.js';
import { createClient } from './client.js';
const { base, close } = await startServer();

const http = axios.create({ baseURL: base, timeout: 300 });
const mine = createClient({ baseUrl: base, timeoutMs: 300 });

// Success: both give { status, data }
const a = await http.get('/books', { params: { q: 'x' } });
const f = await mine.get('/books', { params: { q: 'x' } });
console.log('axios :', a.status, a.data.length, '| fetch client:', f.status, f.data.length);

// POST JSON
console.log('axios :', (await http.post('/books', { title: 'A' })).data);
console.log('fetch :', (await mine.post('/books', { title: 'A' })).data);

// 404
try { await http.get('/missing'); } catch (e) { console.log('axios 404 :', e.name, e.code, e.response.status, e.response.data); }
try { await mine.get('/missing'); } catch (e) { console.log('fetch 404 :', e.name, e.status, e.body); }

// Timeout
try { await http.get('/slow'); } catch (e) { console.log('axios timeout :', e.name, e.code); }
try { await mine.get('/slow'); } catch (e) { console.log('fetch timeout :', e.name); }

// Raw fetch pitfall: leading slash and a base path
console.log(new URL('/books', 'http://host/api/').href, '|', new URL('books', 'http://host/api/').href);
try { await (await fetch(`${base}/lines`)).json(); } catch (e) { console.log('json() on NDJSON:', e.name); }
close();
Output (Node.js 26.10.0 with axios 1.20.0; identical on 22.23.3)
axios : 200 2 | fetch client: 200 2
axios : { id: 3, title: 'A' }
fetch : { id: 3, title: 'A' }
axios 404 : AxiosError ERR_BAD_REQUEST 404 { error: 'not found' }
fetch 404 : HttpError 404 { error: 'not found' }
axios timeout : AxiosError ECONNABORTED
fetch timeout : TimeoutError
http://host/books | http://host/api/books
json() on NDJSON: SyntaxError

A migration checklist that follows from this: grep for validateStatus, interceptors and error.response first, because they are the places where behaviour changes silently; move the timeout to a signal next, since a request that used to be bounded by axios’s timeout is unbounded with a bare fetch; then replace the remaining call sites with the client above; finally remove the dependency and run the tests. Compare with the effort of keeping the dependency honestly: axios is maintained and works, so the migration is about fewer dependencies and one HTTP stack to configure, not about a defect.

Practical guidance

  • Give every outbound request a deadline (AbortSignal.timeout()), and a status check (res.ok); those two lines prevent most outage stories.

  • Always consume or cancel the body, including on error paths, so connections return to the pool.

  • Retry idempotent requests only, with jittered backoff, a cap and Retry-After; make a retried POST safe with an idempotency key.

  • Bound concurrency (mapLimit() or a dispatcher’s connections) before you fan out.

  • Build URLs with URL and searchParams; never concatenate user input into a URL. When the target host comes from user input, validate it against an allow-list to avoid server-side request forgery, because fetch will happily call http://169.254.169.254/ or an internal address for you.

  • Keep secrets out of URLs and logs; send them in headers, read them from process.env (loaded with --env-file), and never log full request headers.

  • Reuse connections: rely on the global agent and the global fetch pool, or keep one long-lived Agent or dispatcher per process, not one per request.

  • In tests, avoid real network calls: point the code at a local server on port 0 as in this page, or use Undici’s MockAgent; see Mocking, Snapshots & Coverage.

  • Where the call is to an LLM provider, the same rules (deadline, streaming, retry on 429 with Retry-After) apply; see Integrating LLM APIs.

What changed recently

Table 9. Changes relevant to HTTP clients
Version Change

v18.0.0

Global fetch available without a flag (added in v17.5.0 and v16.15.0 behind --experimental-fetch).

v19.0.0

http.globalAgent enables keep-alive with a 5 second timeout; earlier lines closed every connection.

v20.3.0, v18.17.0

AbortSignal.any() for combining a deadline with a caller signal.

v21.0.0

fetch, FormData, Headers, Request and Response no longer experimental.

v22.19.0, v24.5.0, v24.6.0

tls.setDefaultCACertificates() (22.19, 24.5) and the NODE_USE_SYSTEM_CA variable (22.19, 24.6) make private CAs a configuration matter instead of a code change.

v22.21.0, v24.0.0, v24.5.0

Built-in proxy support: NODE_USE_ENV_PROXY (22.21, 24.0), --use-env-proxy and proxyEnv on agents (22.21, 24.5), for fetch, http and https.

v24.7.0, v22.20.0

agentKeepAliveTimeoutBuffer option on http.Agent.

v24.14.0, v25.4.0

http.setGlobalProxyFromEnv() for runtime enablement.

v26.3.0

httpValidation option on http.request() ('strict', 'relaxed', 'insecure').

The overall picture of what moved between release lines is in Upgrading & What’s Changed.

Where the books are outdated

This section compares the page with two O’Reilly books consulted while preparing this guide: Efficient Node.js by Samer Buna and Node.js Projects by Jonathan Wexler. Both are published in 2025 and mostly current; the points below are about habits worth updating, not mistakes of the authors.

  • Node.js Projects (Wexler) uses the axios package for server-to-server calls, including a peer-to-peer example and a call to an AI model API, and tells readers on older Node.js lines to install node-fetch. Both pieces of advice are now unnecessary: global fetch is in every supported line (Node.js 18 reached end of life on 2025-04-30), and the Node.js project documents a codemod from axios to fetch. When you adapt those projects, remember that fetch does not reject on error statuses and has no timeout option, as the migration section shows. The same book’s API keys in a .env file loaded by a package are better served today by --env-file, covered in CLI Options, Environment & Config File.

  • Efficient Node.js (Buna) uses fetch in short examples, such as one that feeds several API responses into a stream. Like most book examples they omit what production code needs: an res.ok check, a deadline, retries, and a concurrency limit. Use the helpers above for those.

  • Neither book covers the built-in proxy and system-CA support added in the Node.js 22.19 to 22.21 and 24 lines, the Undici-versus-npm dispatcher compatibility issue, or the http.globalAgent keep-alive default introduced in v19.0.0, so older advice to create a keep-alive agent for every http client is partly obsolete: the global agent already pools connections.

References