How undici, axios, and fetch are layered in Node.js
Node ships two separate HTTP stacks, and the three clients sit on top of them in different ways.
Fetch. Node's global fetch is implemented by a copy of undici bundled into the runtime. When you call fetch, the request goes to undici's global dispatcher, which by default is an undici Agent. The WHATWG API does not include pool size, keep-alive duration, or connect timeouts, so fetch gives you no standard way to set them. Node does accept a non-standard dispatcher option, which lets you hand a specific request to a dispatcher you configured yourself.
Undici. Installing undici as a package gives you the layers that fetch hides:
Client: one origin, one connection, with optional pipelining.Pool: severalClients for a single origin.Agent: routes requests to aPoolper origin.
Its request and stream APIs skip the WHATWG Request/Response objects and expose these dispatchers directly.
Axios. In Node, axios's default adapter calls the core http/https modules. Connection reuse comes entirely from the http.Agent or https.Agent it receives. If you pass none, it uses the module's global agent, so pooling behavior depends on that agent's settings and not on axios.
fetch() undici.request() axios.get()
| | |
WHATWG Request/Response | http adapter
| | |
global dispatcher ---> Agent -> Pool -> Client http.request()
| http(s).Agent
| |
undici's own HTTP/1.1 core http parser
| |
net.Socket / tls.TLSSocket (both stacks)
Undici and fetch share a transport. Axios uses Node's older core stack. In all three cases, how well connections are reused depends on how the agent or dispatcher underneath is configured. The choice of client mainly determines which settings you can reach and how much request-handling code sits on top.
fetch(url, init) undici.request() axios.get(url)
| undici.stream() |
v | v
+------------------+ | +------------------+
| WHATWG Request / | | | axios http |
| Response objects | | | adapter |
+------------------+ | +------------------+
| | | |
| default | init.dispatcher| v
v | (non-standard) v +------------------+
+------------+ | +------------------+ | http.request() / |
| global | +---->| your dispatcher: | | https.request() |
| dispatcher | | Agent | +------------------+
| (Agent) | | -> Pool/origin | |
+------------+ | -> Client | v
| +------------------+ +------------------+
| bundled undici | npm undici | http(s).Agent or |
v v | globalAgent |
+------------------------------------------+ +------------------+
| undici connection layer (own HTTP/1.1) | |
+------------------------------------------+ v
| +------------------+
| | core http parser |
| +------------------+
v |
+-------------------------------------------------------------------+
| net.Socket / tls.TLSSocket (TCP + TLS) |
+-------------------------------------------------------------------+
fetch runs on the undici copy bundled into Node and uses its global dispatcher unless you pass a non-standard dispatcher; the undici package exposes Agent, Pool and Client directly; axios goes through core http/https, so its pooling is set by the http(s).Agent it is given.
How a request works in each client
The layering above determines what happens on every call. Following one request through each stack shows where pooling, timeouts, and body handling come into play.
fetch and undici. The call is handed to a dispatcher. With fetch, that is the global dispatcher unless you pass dispatcher. With undici.request, it is whatever dispatcher you supply, or the global one. An Agent looks up the Pool for the request's origin and creates one if none exists. The pool hands the request to a Client that has an idle connection. If none is idle and the pool is below its connections limit, it opens a new one, which means a DNS lookup, a TCP handshake and, for HTTPS, a TLS handshake. If the pool is full, the request waits in a queue. Undici writes the request, parses the response headers, and resolves your promise with a status, headers, and a body stream. The connection becomes available for the next request only after that body has been fully read, cancelled, or dumped.
axios. The request config passes through any request interceptors. The Node adapter then calls http.request or https.request with the agent from the config. The agent hands back a free keep-alive socket if one exists. Otherwise it opens a new socket if it is below maxSockets, or queues the request. Core http parses the response. By default axios buffers the entire body and parses it (JSON by default). It then applies validateStatus and rejects on non-2xx, and finally runs response interceptors. Because axios reads the body for you unless you ask for a stream, the socket is released without extra code on your side.
In every case, the expensive step is opening a new connection. The client only decides how much of that machinery you can see and configure.
Why connection reuse dominates performance for service calls
Pooling and timeouts are worth tuning, but first it helps to see what reuse actually saves.
A new HTTPS connection costs one round trip for the TCP handshake and, with TLS 1.3, at least one more for the TLS handshake before the request bytes are sent. TLS 1.2 typically adds two. Keep-alive pays this cost once and spreads it across every later request on that socket.
Illustrative estimate
Assumptions: 10 ms RTT between services, TLS 1.3 (one round trip), 5 ms server processing time, 500 requests/s, and roughly 1 ms of combined client and server CPU per full handshake.
- No reuse: 10 ms (TCP) + 10 ms (TLS) + 10 ms (request/response) + 5 ms (server) = 35 ms per call. That is 500 handshakes per second, or about half a CPU core spent on key exchange.
- With reuse: 15 ms per call. Handshakes happen only when a pooled socket is opened or replaced, so their cost is close to zero at steady state.
Reuse removes more than half the latency in this example. No client library's per-request overhead comes close to that.
This is why micro-benchmarks of client overhead mislead. They measure tens of microseconds of parsing and object allocation against a local server, where network and server time are essentially zero. In production those differences are lost in milliseconds of RTT and server time.
Switching libraries also leaves several problems untouched:
- DNS: slow lookups still happen on every new connection.
- TLS: a misconfigured certificate chain or cipher setup still costs the same.
- Downstream saturation: a saturated downstream service still queues your requests, whichever client sends them.
Configuring connection pooling and keep-alive in each client
Axios: one shared agent
Axios uses Node's http/https modules, so pooling comes from the agent you pass via httpAgent and httpsAgent. Create one https.Agent with keepAlive: true at startup and attach it to a single long-lived axios instance. Defaults differ across Node versions, so set the agent explicitly.
maxSocketscaps concurrent connections per origin. Requests beyond the cap queue inside the agent.maxFreeSocketscaps how many idle connections stay open for reuse.
Never create an agent or axios instance per request. Each new agent has an empty pool, so every call pays TCP and TLS setup again, and the abandoned sockets linger until they time out.
Undici: a Pool per origin
An undici Pool manages connections to exactly one origin. Its connections option sets the pool size. An Agent creates and manages one pool per origin for you. pipelining allows several in-flight requests on one connection.
Fetch: dispatchers
Node's built-in fetch is built on undici. To configure it, install a dispatcher globally with setGlobalDispatcher, or pass one per request through the dispatcher option where the runtime supports it. Which options are accepted, and how the bundled undici interacts with a separately installed undici package, can differ between Node versions. Verify this against the version you run.
Trade-offs
A larger pool raises throughput only until the downstream service saturates. Beyond that point, the pool hides the slowness as client-side queueing, and latency grows without obvious errors. Pipelining adds head-of-line blocking: one slow response delays every request queued behind it on that connection. When a pipelined connection drops, the queued requests may be replayed, which is unsafe for non-idempotent methods like POST.
A single global dispatcher is simpler to set up. Per-origin configuration is more correct, because each downstream can get limits that match its capacity, but it is more work to maintain.
import https from 'node:https';
import axios from 'axios';
import { Pool, Agent, setGlobalDispatcher } from 'undici';
// Axios: one keep-alive agent and one axios instance, created once at startup.
const httpsAgent = new https.Agent({
keepAlive: true, // set explicitly; defaults vary across Node versions
maxSockets: 50, // concurrent connections per origin; extra requests queue
maxFreeSockets: 10 // idle connections kept open for reuse
});
export const billingApi = axios.create({
baseURL: '',
httpsAgent,
timeout: 5000
});
// Wrong: calling axios.create({ httpsAgent: new https.Agent(...) }) inside a
// handler gives every request an empty pool and a fresh TCP + TLS handshake.
// Undici: a Pool is bound to exactly one origin.
export const inventoryPool = new Pool('', {
connections: 20, // pool size for this origin
pipelining: 1 // raise only for idempotent traffic; >1 risks head-of-line blocking
});
export async function getItem(id) {
const { statusCode, body } = await inventoryPool.request({ path: `/items/${id}`, method: 'GET' });
if (statusCode !== 200) {
await body.dump(); // drain the body so the connection returns to the pool
throw new Error(`inventory returned ${statusCode}`);
}
return body.json();
}
// Fetch: one global dispatcher applies to every origin fetch talks to.
// An Agent keeps a separate pool per origin, each capped at `connections`.
// Check that the undici package version is compatible with the undici bundled
// in your Node version; mismatches behave differently across Node releases.
setGlobalDispatcher(new Agent({ connections: 20, keepAliveTimeout: 10_000 }));
const res = await fetch('');
Pooling setup for each client: a single shared keep-alive https.Agent for axios, a per-origin undici Pool with pipelining left at 1, and a global undici Agent installed as the dispatcher for fetch.
Timeouts, deadlines, and response body handling
A request can stall in four distinct phases, and each needs its own bound:
- Connect: TCP and TLS setup to the origin.
- Headers / first byte: the server has accepted the request but has not responded.
- Body / idle: headers arrived, but the body stops flowing between chunks.
- Total deadline: wall-clock time for the whole call, including retries.
Undici exposes the first three on the dispatcher (headersTimeout, bodyTimeout, and a connect timeout). For the total deadline, pass an AbortSignal (for example from AbortSignal.timeout()) to fetch or undici.request. Aborting cancels the request wherever it is, including mid-body.
Axios accepts a timeout option and a signal. A single timeout number cannot tell you whether you lost time to a slow handshake or to a stalled body stream, and it does not let you bound those phases separately. Use signal for the deadline. Keep timeout as a backstop or leave it out.
Derive the deadline from the inbound request, not from a constant. If your caller allows 2 seconds and 1.4 seconds have already passed, the downstream call gets what remains, minus a small margin for your own response. A fixed 5-second timeout in that situation does work nobody will wait for.
Body handling is a correctness issue. With fetch and undici, a response whose body you never read keeps its connection checked out. Under load, these leaked responses can starve the pool even when no request is actually busy. Garbage collection does not release them promptly. This happens most often on error paths, where code checks res.ok, throws, and never touches the body.
On every path, do one of the following:
- Read the body fully.
- Call
res.body.cancel()withfetch. - Call
body.dump()withundici.request. This drains a bounded amount of data so the socket can be reused.
import { Agent, request } from 'undici';
// Per-phase bounds live on the dispatcher; the total deadline comes from a signal.
const dispatcher = new Agent({
connect: { timeout: 1_000 }, // TCP + TLS setup
headersTimeout: 1_500, // waiting for response headers
bodyTimeout: 1_000, // max idle gap between body chunks
});
const MARGIN_MS = 100; // time we keep for building our own response
// inboundDeadline: epoch ms by which our caller needs an answer.
function budgetSignal(inboundDeadline) {
const remaining = inboundDeadline - Date.now() - MARGIN_MS;
if (remaining <= 0) throw new Error('deadline already exceeded');
return AbortSignal.timeout(remaining);
}
export async function getUser(id, inboundDeadline) {
const { statusCode, body } = await request(` {
dispatcher,
signal: budgetSignal(inboundDeadline), // aborts in any phase, including mid-body
});
if (statusCode !== 200) {
await body.dump(); // drain so the connection returns to the pool
throw new Error(`users service returned ${statusCode}`);
}
return body.json(); // reading fully also releases the connection
}
// Same rule with fetch: cancel the body on error paths.
export async function getUserWithFetch(id, inboundDeadline) {
const res = await fetch(` {
signal: budgetSignal(inboundDeadline),
});
if (!res.ok) {
await res.body?.cancel(); // don't leave the body unread
throw new Error(`users service returned ${res.status}`);
}
return res.json();
}
An undici Agent bounds the connect, headers, and body-idle phases, an AbortSignal derived from the inbound request's remaining budget enforces the total deadline, and error paths dump or cancel the response body so the connection goes back to the pool.
Keep-alive, idle timeouts, and safe retries
An intermittent ECONNRESET or "socket hang up" on a healthy service usually comes from a race on an idle connection. The server or load balancer decides a pooled socket has been idle too long and closes it. At the same moment, the client takes that socket from its pool and writes a request. The FIN or RST arrives after the write, so the request fails even though nothing is broken.
To avoid this race, set the client's keep-alive timeout lower than every upstream idle timeout, with a margin of a second or two. With undici this is keepAliveTimeout on the Agent or Pool. With axios, configure the http.Agent you pass in. With Node's global fetch, install a configured undici dispatcher. Long timeouts reuse more sockets but leave more stale ones in the pool. Short timeouts avoid stale sockets but add TCP and TLS handshakes to more requests.
Retries handle the races that still slip through. None of the three clients ships a retry policy that is safe by default, so define one yourself:
- Retry only idempotent methods, or requests that carry an idempotency key the server honors.
- Use exponential backoff with jitter so failed clients don't retry in lockstep.
- Cap total retry time inside the caller's deadline. A retry that finishes after the deadline wastes capacity.
Retries also multiply across service layers.
Illustrative estimate (assumed numbers, not a benchmark): total attempts = aᴸ, where a is attempts per layer and L is the number of layers. With a = 3 and L = 3, one failing user request becomes 3³ = 27 calls to the deepest service.
To limit this, retry at one layer only and add a circuit breaker so a failing dependency gets fewer calls instead of more.
import { Agent, setGlobalDispatcher } from 'undici';
// Assumed upstream LB idle timeout: 10s. Close idle sockets on the client first.
setGlobalDispatcher(new Agent({
keepAliveTimeout: 8_000, // idle time before the client drops a pooled socket
keepAliveMaxTimeout: 8_000, // upper bound even if the server advertises longer
}));
const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
export async function fetchWithRetry(
url: string, init: RequestInit = {}, deadlineMs = 2_000, maxAttempts = 3,
): Promise<Response> {
const method = (init.method ?? 'GET').toUpperCase();
// Retry only idempotent methods or requests carrying an idempotency key.
const retryable = IDEMPOTENT.has(method) || new Headers(init.headers).has('Idempotency-Key');
const deadline = Date.now() + deadlineMs;
for (let attempt = 1; ; attempt++) {
try {
// Each attempt gets only the time left in the caller's deadline.
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(deadline - Date.now()) });
if (res.status < 500 || !retryable || attempt >= maxAttempts) return res;
await res.body?.cancel(); // drain so the socket can return to the pool
} catch (err) {
if (!retryable || attempt >= maxAttempts) throw err;
}
// Exponential backoff with full jitter so clients don't retry in lockstep.
const delay = Math.random() * 100 * 2 ** attempt;
if (Date.now() + delay >= deadline) {
throw new Error('retry budget exhausted within deadline');
}
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
Sets an undici keep-alive timeout below an assumed 10-second upstream idle timeout for global fetch, and wraps fetch in a retry helper that retries only idempotent or keyed requests, uses jittered exponential backoff, and stops within the caller's deadline.
Ergonomics and error handling compared
Each client hands you a different default view of a failed call, and that default decides how much error and retry code you write yourself.
| Concern | axios | fetch (global) | undici.request |
|---|---|---|---|
| Response shape | data already parsed (JSON by default) |
Response with a stream body; you call .json() or .text() |
statusCode, headers, and a body stream |
| Non-2xx status | Rejects by default (validateStatus) |
Resolves; you check response.ok |
Resolves; you check statusCode |
| Cross-cutting hooks | Request and response interceptors | None; you wrap the function | None; you use dispatcher composition or a wrapper |
| Dependency footprint | Separate npm package plus its dependencies | Built into Node | npm package with a small API surface |
| Portability | Browser and Node | Web standard | Node only |
| Pooling control | httpAgent/httpsAgent (keep-alive agent) |
Global dispatcher or the non-standard dispatcher option |
Agent/Pool directly |
| Timeout granularity | One timeout value, plus signal |
AbortSignal only |
headersTimeout and bodyTimeout, plus signal |
With axios, a 503 and a refused connection both reject. You tell them apart by checking whether err.response exists, and retry logic usually lives in a response interceptor. With fetch and undici, a 503 is a normal return value. Only network failures and aborts throw, so the retry decision has two code paths. Before you retry, you must consume or cancel the unread body. Otherwise the connection is not returned to the pool.
Raw undici is cheap to run and expensive to write. You own JSON parsing, status checks, and body draining on every call site, so teams typically centralize this in one internal wrapper. Axios gives you those defaults for free. In exchange you take on a dependency to keep patched and an abstraction layer between your code and the connection pool.
How a well-configured client saves engineering time
Most of the time spent on HTTP clients in services goes into debugging, not writing calls. That means chasing intermittent ECONNRESET errors, p99 spikes from pool exhaustion, hung requests with no timeout, and retry storms. The practices in this article cut that time in a few concrete ways:
- One shared client module per origin. Pool size, keep-alive timeout, per-phase timeouts, and retry policy live in one reviewed file. Call sites become one-liners that cannot forget to drain a body or set a deadline. When a downstream changes its idle timeout or capacity, you change one value instead of auditing every caller.
- Fixing configuration instead of migrating libraries. Most latency and reliability problems trace back to missing keep-alive, an agent created per request, or keep-alive timeouts longer than the load balancer's. Recognizing that lets a team fix a problem in a few lines rather than spending a sprint rewriting call sites onto a different client that will have the same problem if misconfigured.
- Failures that explain themselves. Separate connect, headers, and body timeouts tell you which phase stalled. A single opaque timeout leaves you guessing between DNS, TLS, a slow server, and a stalled stream. Deadlines derived from the inbound request also stop wasted downstream work from showing up as confusing load during incidents.
- Choosing the client that matches existing code. Using axios where interceptors already carry auth and logging,
fetchwhere you want no extra dependency, and undici only where its control is needed avoids both a needless rewrite and a needless dependency to patch. - Measuring configurations, not libraries. A load test that compares a shared agent against no agent, or a default dispatcher against a tuned one, answers the question that actually affects production. Library shoot-outs on a local server rarely do.
Who should use each client
- Teams writing Node.js backend services that make outbound HTTP calls to other services, internal APIs, or third parties. This is the audience for everything above, because pooling, timeouts, and retries only matter once a process makes repeated calls under load.
- Platform or infrastructure teams that own a shared HTTP wrapper or internal SDK. They benefit most from undici's explicit
Agent/Poolcontrol and per-phase timeouts, because they configure the client once for many service teams. - Teams on recent Node versions who want no extra dependency. Global
fetchwith a tuned global dispatcher covers most moderate-volume service calls and uses the same API as browser code. - Teams with existing axios code or code shared between browser and Node. Staying on axios with one shared keep-alive agent is usually cheaper than migrating, and interceptors keep cross-cutting concerns in one place.
- Teams running high-throughput internal hops, such as gateways or aggregators that fan out to many backends. Direct
undici.requestwith per-origin pools gives the most control, as long as the team is disciplined about status checks and body draining.
Short-lived scripts, CLIs, and jobs that make a handful of requests rarely need any of this tuning. Whichever client is most convenient is fine there.
Which client to use, when to avoid each, and how to measure
The decision usually depends less on raw speed than on which failure modes your team is equipped to handle.
| Client | Use it for | Avoid it when |
|---|---|---|
fetch |
Moderate-volume calls where portability and standard semantics matter, backed by a tuned global dispatcher | You need per-origin pool control on a high-throughput path |
undici request / Pool |
High-throughput internal hops where low overhead and explicit pool control pay off | The team cannot reliably consume or discard every body and check status codes, since leaked bodies hold sockets |
| axios | Codebases already built on interceptors, or code shared between browser and Node, paired with one shared keep-alive agent | Hot paths where someone is likely to construct an agent per request |
Whichever client you choose, standardize on one well-configured shared client per origin. That puts the pool size, timeouts, and retry policy in one reviewed place. When latency regresses, you change one file instead of hunting through dozens of call sites with different settings.
How to measure
Load-test your own workload with realistic payload sizes and concurrency. Track:
- Latency. Record p50 and p99. Pool exhaustion and connection setup show up in p99 well before they affect p50.
- Connection count and socket churn. Sample with
ss -tanornetstat. A growing count ofTIME_WAITsockets means connections are not being reused. - Event loop utilization. Use
performance.eventLoopUtilization()fromperf_hooksto see whether the client's CPU overhead matters at your request rate.
Compare configurations, not libraries. For example, axios with a shared agent against axios without one, or fetch with the default dispatcher against a tuned one. The difference between configurations usually outweighs the difference between clients.
Alternatives to consider
The core http and https modules
Calling http.request with your own http.Agent gives you the same pooling controls axios relies on, without the extra layer. This makes sense for small internal libraries where you want zero dependencies and full control over socket events. The cost is that you handle streaming, redirects, body parsing, and error normalization yourself. That code is easy to get subtly wrong, especially when a response body is never consumed and the socket never returns to the pool.
HTTP/2 via node:http2
If a downstream service supports HTTP/2, a single session multiplexes many concurrent requests over one connection. That removes most pool-sizing questions. The tradeoff is head-of-line blocking at the TCP layer. A single session also concentrates failure: if it dies, every in-flight request on it fails together, so you need session health checks and reconnection logic. The core module's API is lower-level than any client discussed here.
RPC frameworks and generated clients
For service-to-service traffic you control on both ends, gRPC or an OpenAPI-generated client moves the contract into a schema. You get typed requests and consistent error shapes. Some frameworks also provide deadline propagation. The transport underneath still needs the same pooling and timeout tuning.
Offloading to a sidecar proxy
A service mesh or local proxy can own retries, timeouts, and connection pooling outside the process. It applies the same policy across all languages in a fleet. You still need sensible client-side timeouts, though. Without them, the client keeps waiting after the proxy has already given up.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to undici vs axios vs fetch, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.