The HTTP client deep dive: undici, pools, timeouts, and leaked sockets
undici is the modern Node HTTP client and what global fetch runs on. Reuse connections with a pool, set timeouts, retry only idempotent requests, and always drain bodies or you leak sockets.
The checkout service was healthy until Black Friday traffic hit, then p99 latency on a downstream call to the payments API jumped from 40ms to 900ms — but the payments API itself was fine. The cause was in our own client: every outbound request used http.request with the default agent, which opens a fresh TCP connection and a full TLS handshake per call. At ten requests per second that was ten handshakes a second to the same host, each adding two round-trips before a single byte of payload moved. We had been paying for a connection pool we never turned on. A second outage that quarter was worse: a worker leaked file descriptors until EMFILE, because someone read res.statusCode, branched on it, and returned early — never consuming the response body. Each abandoned body pinned a socket that never went back to the pool.
undici, and why fetch already is undici
Node ships two HTTP clients. The legacy one is http/https (http.request, http.get). The modern one is undici — written specifically for Node, with an HTTP/1.1 keep-alive connection pool, far better throughput, and a cleaner promise-based API. You do not have to choose between “use fetch” and “use undici”: in Node, the global fetch is built on undici. When you call fetch(url), the request is dispatched through undici’s global Agent, so tuning undici tunes fetch.
import { request, Agent, Pool, setGlobalDispatcher } from "undici";
// Direct undici request — returns { statusCode, headers, body }.
const { statusCode, body } = await request("https://api.example.com/orders");
const orders = await body.json(); // consumes the body
// fetch IS undici: pass a custom pool via the dispatcher option.
const pool = new Pool("https://api.example.com", { connections: 64 });
const res = await fetch("https://api.example.com/orders", { dispatcher: pool });Agent manages a pool per origin (it spins up a Pool for each host you talk to); Pool manages many connections to one origin; Client is a single connection to one origin. Most apps use an Agent — directly, or implicitly through fetch.
▸Why this works
Why does fetch in Node behave like undici rather than like the browser? Because Node’s fetch is not a separate implementation — it is undici’s fetch exposed on the global scope, and it uses undici’s global dispatcher (an Agent) under the hood. That is great: you get connection pooling for free, and you can swap in a tuned pool per call with the dispatcher option, or for the whole process with setGlobalDispatcher(new Agent({ connections: 128 })). It is also a trap if you assume fetch is “just HTTP with no state” — it shares a pool, so a leaked body or an exhausted pool in one part of your code degrades fetch everywhere.
Keep-alive: pay the handshake once, not per request
A new HTTP request over a new connection costs a TCP handshake (one round-trip) plus, for HTTPS, a TLS handshake (one or two more). Over a 30ms link that is 60–90ms of pure setup before any data flows — and you pay it on every request if connections are not reused. Keep-alive holds the TCP+TLS connection open after the response so the next request to the same origin reuses it, amortising the handshake to near zero.
This is the single biggest reason the legacy default is a trap. Since Node 19 the global agent does enable keep-alive, but it stays untuned — maxSockets is Infinity and there is no real pool discipline — so at scale you still construct an agent explicitly to bound and reuse connections deliberately. undici keeps connections alive and pools them by default.
// Legacy http: global agent keep-alives since Node 19, but is untuned —
// set an explicit Agent to bound maxSockets and reuse deliberately.
import http from "node:http";
const agent = new http.Agent({ keepAlive: true, maxSockets: 64 });
http.get({ host: "api.example.com", path: "/x", agent }, (res) => {
res.resume(); // drain — see below
});
// undici: keep-alive pool is the default; just reuse one dispatcher.
import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ connections: 64, pipelining: 1 }));The connections option caps how many connections undici opens per origin (an Agent builds one Pool per origin, and connections limits that pool); pipelining controls how many requests may be in flight on one connection (default 1 — leave it there unless you have measured a benefit, because HTTP/1.1 pipelining causes head-of-line blocking). Size the pool to your downstream’s capacity, not to infinity: an unbounded pool turns a traffic spike into a connection flood that knocks the downstream over.
| Concern | http.request (default agent) | undici / global fetch |
|---|---|---|
| Keep-alive | On since Node 19 but untuned (maxSockets: Infinity) — set new http.Agent({ keepAlive: true, maxSockets }) | On by default; pooled per origin |
| Pool size knob | maxSockets | connections (per origin) |
| API | Callback / stream | Promise; body.json() etc. |
| Body must be drained? | Yes (res.resume()) | Yes (body.dump() / consume) |
Timeouts and retries: bound everything, retry almost nothing
A request with no timeout can hang forever, and a hung request holds a pooled connection out of circulation. undici exposes three knobs that matter: headersTimeout (time to receive the response headers, default 300e3 = 300s), bodyTimeout (max gap between body chunks, default 300e3), and the connect timeout (TCP/TLS establishment, default 10e3 = 10s). The 300-second defaults are generous — for a user-facing call you want single-digit seconds, set per-dispatcher.
import { Agent } from "undici";
const agent = new Agent({
connections: 64,
connect: { timeout: 2_000 }, // give up establishing after 2s
headersTimeout: 5_000, // headers must arrive within 5s
bodyTimeout: 10_000, // no body chunk for 10s -> abort
});| Knob | Guards against | undici default |
|---|---|---|
connect.timeout | Dead host / SYN black hole | 10s |
headersTimeout | Server accepts but never responds | 300s |
bodyTimeout | Slow / stalled streaming body | 300s |
Retries are where naive code is dangerous. Retrying a GET is usually safe — it is idempotent, so running it twice has the same effect as once. Retrying a POST /charge is not: a timeout means you do not know whether the charge went through, and a blind retry can double-bill the customer. Worse, when a downstream is already overloaded and every client retries on failure, you get a retry storm — the failure triggers a wave of duplicate requests that buries the struggling service deeper. Safe retry policy: retry only idempotent methods (or requests carrying an idempotency key), cap attempts, and use exponential backoff with jitter so retries spread out instead of synchronising.
The body you must always drain
In undici, every response body must be fully consumed or destroyed. When you see a pool slowly starving under load with no obvious CPU spike, an unconsumed body is the first place to look. A connection cannot be returned to the pool until its current response is finished reading — the protocol has to know where this response ends before the connection can carry the next request. If you read statusCode, branch, and return without touching body, that connection is stuck. Under load the pool runs out of free connections (socket exhaustion), and because each held connection is an open file descriptor, you eventually hit EMFILE and ECONNRESET storms as the OS and peers tear things down.
import { request } from "undici";
const { statusCode, body } = await request("https://api.example.com/orders");
if (statusCode === 200) {
return await body.json(); // consumes the body — connection freed
}
await body.dump(); // not interested? dump it anyway, don't leak
return null;The rule is unconditional: consume the body (body.json(), body.text(), body.arrayBuffer(), or stream it to completion) on the happy path, and body.dump() it on every path where you bail out early. The same applies to legacy http: call res.resume() to drain a response you do not read.
Under load your worker climbs to EMFILE and the pool stops serving requests. The hot path reads res.statusCode, logs it, and returns without reading the body. Most likely cause?
A request to charge a customer (POST /charge) times out. You don't know if it succeeded. What retry policy is correct?
- 01Why must you always consume or dump an undici response body, and what breaks if you don't?
- 02Why is retrying a POST /charge after a timeout dangerous, and what is the safe policy?
The client side of HTTP in Node has one modern answer: undici, which is also what the global fetch runs on, so the request you send with fetch flows through undici’s pooled Agent and you can swap in a tuned pool with the dispatcher option. The first lever is connection reuse — keep-alive holds the TCP+TLS connection open so you pay the handshake once instead of per request; undici does this and pools by default, while the legacy global agent keep-alives since Node 19 but stays untuned (unbounded maxSockets), so at scale you set an explicit new http.Agent({ keepAlive: true, maxSockets }). Size the pool with connections to your downstream’s real capacity rather than infinity, and leave pipelining at 1 unless measured. Bound every request: a connect timeout for dead hosts, headersTimeout for servers that accept but never answer, bodyTimeout for stalled streams — the 300-second defaults are far too generous for user-facing calls. Retry almost nothing: only idempotent methods or requests with an idempotency key, capped and with exponential backoff plus jitter, or a naive retry double-bills customers and a synchronised wave becomes a retry storm. And the rule that quietly takes services down: always consume or body.dump() the response, because an undrained body pins its connection out of the pool, exhausts sockets, and leaks file descriptors until EMFILE and ECONNRESET. Now when you see a service slowly grinding to a halt on Black Friday with growing open FDs and no obvious leak in business logic, check every early-return path for a missing body.dump() — that one line is often the entire fix.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.