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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
Global |
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; |
|
Stable |
|
|
2 Stable |
|
|
2 Stable |
Keep-alive enabled with a 5 second timeout by default since v19.0.0. |
|
1.1 Active development (Built-in Proxy Support) |
Added in v24.5.0 and v22.21.0. |
|
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. |
|
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). |
|
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). |
|
No separate banner |
|
|
No separate banner |
Added in v7.3.0. Read only when the process starts. |
Source: Node.js API: fetch, Node.js API: HTTP, Built-in Proxy Support, Node.js API: CLI, NODE_USE_SYSTEM_CA
The Undici version differs per release line, and so do some of its behaviours. In the runs for this page the bundled versions were:
| 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.
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() };
}
Source: Node.js API: http.createServer()
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.
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();
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).
| Situation | Result |
|---|---|
The server answers |
The promise resolves. Check |
DNS failure, refused connection, TLS error, reset socket |
Rejects with |
A relative URL, or a blocked "bad port" such as 1 |
Rejects with |
The |
Rejects with the signal’s |
|
Rejects with |
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.
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();
{ 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.
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();
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.
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();
{ 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:
| Rule | Why |
|---|---|
Retry only idempotent methods ( |
Repeating a |
Retry network errors and the statuses |
These usually mean "try again"; a |
Exponential backoff with full jitter |
The delay is a random value between zero and |
Honour |
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 |
Cancel the failed response body before waiting |
An unread body would hold its connection for the whole backoff. |
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 });
}
}
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();
Source: Node.js API: fetch, RFC 9110: Retry-After
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.
// 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;
}
Source: Node.js API: fetch
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();
Source: Node.js API: fetch
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.
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:
| Option or object | Meaning |
|---|---|
|
Used when you pass no |
|
A fresh agent has |
|
A throwaway agent with default options for one request: no reuse. |
|
Keep idle sockets for reuse; |
|
Milliseconds subtracted from the server’s |
|
Per-host and overall caps on open sockets (default |
|
Idle sockets kept per host when |
|
|
|
Socket timeout in milliseconds, applied when the socket is created. |
|
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.
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();
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.
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();
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:
| Mechanism | What it does |
|---|---|
|
Reads |
|
The same, as a flag; the flag takes precedence if both are set. The variables can come from |
|
Turns support on at runtime (v25.4.0, v24.14.0), reading |
|
Proxy settings for one agent only (v24.5.0, v22.21.0): |
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.
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
Source: Node.js API: HTTP, Built-in Proxy Support, Node.js API: http.Server, Event: 'connect', Node.js API: net.connect()
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();
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
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.
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();
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.
| Mechanism | Scope | Versions |
|---|---|---|
|
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 |
|
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 |
|
Whole thread, at runtime; call it before the first TLS connection |
v22.19.0 and v24.5.0 ( |
The |
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.
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 ..
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() };
}
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();
Source: Node.js API: CLI, NODE_EXTRA_CA_CERTS, Node.js API: tls.getCACertificates(), Node.js API: tls.setDefaultCACertificates()
node e11-tls.mjs
NODE_EXTRA_CA_CERTS=certs/ca.pem node e11-tls.mjs
Source: Node.js Learn: Enterprise Network Configuration, Node.js API: tls.setDefaultCACertificates(), Node.js API: CLI, NODE_EXTRA_CA_CERTS
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.
| Concern | axios | fetch (and what to write) |
|---|---|---|
Status errors |
Rejects on non-2xx ( |
Resolves. Check |
Response body |
Parsed into |
Call |
Request JSON |
Object |
|
Query string |
|
|
Base URL |
|
|
Timeout |
|
|
Cancellation |
|
|
Interceptors |
|
A wrapper function around |
Connection pooling and proxy |
|
Undici dispatcher, |
Upload and download progress |
|
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.
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),
};
}
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();
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 retriedPOSTsafe with an idempotency key. -
Bound concurrency (
mapLimit()or a dispatcher’sconnections) before you fan out. -
Build URLs with
URLandsearchParams; 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, becausefetchwill happily callhttp://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
fetchpool, or keep one long-livedAgentor 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
429withRetry-After) apply; see Integrating LLM APIs.
What changed recently
| Version | Change |
|---|---|
v18.0.0 |
Global |
v19.0.0 |
|
v20.3.0, v18.17.0 |
|
v21.0.0 |
|
v22.19.0, v24.5.0, v24.6.0 |
|
v22.21.0, v24.0.0, v24.5.0 |
Built-in proxy support: |
v24.7.0, v22.20.0 |
|
v24.14.0, v25.4.0 |
|
v26.3.0 |
|
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: globalfetchis 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 thatfetchdoes not reject on error statuses and has notimeoutoption, as the migration section shows. The same book’s API keys in a.envfile loaded by a package are better served today by--env-file, covered in CLI Options, Environment & Config File. -
Efficient Node.js (Buna) uses
fetchin short examples, such as one that feeds several API responses into a stream. Like most book examples they omit what production code needs: anres.okcheck, 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.globalAgentkeep-alive default introduced in v19.0.0, so older advice to create a keep-alive agent for everyhttpclient is partly obsolete: the global agent already pools connections.
References
-
Node.js Learn: Fetching data with Node.js, https://nodejs.org/learn/getting-started/fetch
-
Node.js Learn: Enterprise Network Configuration, https://nodejs.org/learn/http/enterprise-network-configuration
-
Node.js Learn: Axios to WHATWG Fetch, https://nodejs.org/learn/userland-migrations/axios-to-whatwg-fetch
-
Node.js API: Globals, fetch, https://nodejs.org/docs/latest/api/globals.html#fetch
-
Node.js API: Globals, Custom dispatcher, https://nodejs.org/docs/latest/api/globals.html#custom-dispatcher
-
Node.js API: Globals, AbortSignal.timeout(), https://nodejs.org/docs/latest/api/globals.html#static-method-abortsignaltimeoutdelay
-
Node.js API: Globals, AbortSignal.any(), https://nodejs.org/docs/latest/api/globals.html#static-method-abortsignalanysignals
-
Node.js API: Globals, FormData, https://nodejs.org/docs/latest/api/globals.html#class-formdata
-
Node.js API: HTTP, https://nodejs.org/docs/latest/api/http.html
-
Node.js API: HTTP, http.Agent, https://nodejs.org/docs/latest/api/http.html#class-httpagent
-
Node.js API: HTTP, http.request(), https://nodejs.org/docs/latest/api/http.html#httprequesturl-options-callback
-
Node.js API: HTTP, http.globalAgent, https://nodejs.org/docs/latest/api/http.html#httpglobalagent
-
Node.js API: HTTP, Built-in Proxy Support, https://nodejs.org/docs/latest/api/http.html#built-in-proxy-support
-
Node.js API: HTTP, http.Server, Event: 'connect', https://nodejs.org/docs/latest/api/http.html#event-connect-1
-
Node.js API: HTTP, http.setGlobalProxyFromEnv(), https://nodejs.org/docs/latest/api/http.html#httpsetglobalproxyfromenvproxyenv
-
Node.js API: HTTP, http.createServer(), https://nodejs.org/docs/latest/api/http.html#httpcreateserveroptions-requestlistener
-
Node.js API: HTTPS, https.createServer(), https://nodejs.org/docs/latest/api/https.html#httpscreateserveroptions-requestlistener
-
Node.js API: Events, events.once(), https://nodejs.org/docs/latest/api/events.html#eventsonceemitter-name-options
-
Node.js API: Net, net.connect(), https://nodejs.org/docs/latest/api/net.html#netconnect
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: CLI, --use-env-proxy, https://nodejs.org/docs/latest/api/cli.html#--use-env-proxy
-
Node.js API: CLI, NODE_USE_ENV_PROXY, https://nodejs.org/docs/latest/api/cli.html#node_use_env_proxy1
-
Node.js API: CLI, NODE_USE_SYSTEM_CA, https://nodejs.org/docs/latest/api/cli.html#node_use_system_ca1
-
Node.js API: CLI, NODE_EXTRA_CA_CERTS, https://nodejs.org/docs/latest/api/cli.html#node_extra_ca_certsfile
-
Node.js API: CLI, NODE_TLS_REJECT_UNAUTHORIZED, https://nodejs.org/docs/latest/api/cli.html#node_tls_reject_unauthorizedvalue
-
Node.js API: TLS, tls.setDefaultCACertificates(), https://nodejs.org/docs/latest/api/tls.html#tlssetdefaultcacertificatescerts
-
Node.js API: TLS, tls.getCACertificates(), https://nodejs.org/docs/latest/api/tls.html#tlsgetcacertificatestype
-
Node.js API: Web Streams, https://nodejs.org/docs/latest/api/webstreams.html
-
Node.js API: Stream, stream.Readable.fromWeb(), https://nodejs.org/docs/latest/api/stream.html#streamreadablefromwebreadablestream-options
-
Node.js API: Timers, timers/promises setTimeout(), https://nodejs.org/docs/latest/api/timers.html#timerspromisessettimeoutdelay-value-options
-
Undici documentation, https://undici.nodejs.org/
-
Undici: Agent, https://github.com/nodejs/undici/blob/main/docs/docs/api/Agent.md
-
Undici: Client, https://github.com/nodejs/undici/blob/main/docs/docs/api/Client.md
-
WHATWG Fetch Standard, https://fetch.spec.whatwg.org/
-
RFC 9110: HTTP Semantics, Retry-After, https://www.rfc-editor.org/rfc/rfc9110.html#field.retry-after
-
OpenSSL: openssl-req, https://docs.openssl.org/master/man1/openssl-req/
-
OpenSSL: openssl-x509, https://docs.openssl.org/master/man1/openssl-x509/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/