Microbenchmarks that hit http://127.0.0.1 lie. Production fan-out pays for TLS handshakes, DNS, idle pool eviction, and slow peers. Global fetch (undici under the hood), Got, and raw undici Client/Pool/Agent behave differently once you churn connections — and the wrong default creates keep-alive storms or socket starvation.
⚡ TL;DR: Use undici
Agent/Poolwith explicitconnections,pipelining, and timeouts for high-QPS egress; usefetchfor simple app code with a shared dispatcher; keep Got only where its hooks/retry ecosystem still earns its keep. Benchmark with TLS + multiple hosts. See also HTTP/2 in Node Behind ALB for protocol-level cousins of these failures.
What actually differs under churn
| Surface | Pooling | Retries | Hooks | Best fit |
|---|---|---|---|---|
fetch |
via dispatcher |
DIY | limited | app-level calls |
| Got | yes | built-in | rich | legacy scripts |
| undici Pool/Agent | first-class | DIY | low-level | platforms / gateways |
// src/http/egress.ts
import { Agent, fetch as undiciFetch } from "undici";
// ✅ One shared agent per process — sized for fan-out
export const egressAgent = new Agent({
connections: 32,
pipelining: 1, // keep 1 unless you know the peer is safe
keepAliveTimeout: 30_000,
keepAliveMaxTimeout: 60_000,
connect: { timeout: 5_000, rejectUnauthorized: true },
bodyTimeout: 30_000,
headersTimeout: 30_000,
});
export function apiFetch(url: string, init?: RequestInit) {
return undiciFetch(url, { ...init, dispatcher: egressAgent } as any);
}
// ❌ New Agent/Got instance per request — TLS churn amplifier
export const bad = (url: string) => fetch(url); // default agent OK
export const worse = async (url: string) => {
const { default: got } = await import("got");
return got(url); // still OK-ish
};
export const worst = (url: string) =>
undiciFetch(url, { dispatcher: new Agent() } as any);
Benchmark recipe that matches prod
Drive traffic at multiple hostnames with forced connection limits and TLS. Measure p50/p99 latency, RSS, and sockets in TIME_WAIT.
// scripts/bench-egress.ts
import { performance } from "node:perf_hooks";
import { apiFetch } from "../src/http/egress.js";
async function run(name: string, n: number, concurrency: number, url: string) {
const lat: number[] = [];
let i = 0;
async function worker() {
while (i < n) {
const k = i++;
const t0 = performance.now();
const res = await apiFetch(url);
await res.arrayBuffer();
lat.push(performance.now() - t0);
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()));
lat.sort((a, b) => a - b);
console.log(name, { p50: lat[Math.floor(n * 0.5)], p99: lat[Math.floor(n * 0.99)] });
}
Retries and idempotency
Got’s automatic retries are convenient and dangerous on POST. undici/fetch force you to be explicit — that is a feature for platforms.
export async function getWithRetry(url: string, attempts = 3) {
let last: unknown;
for (let i = 0; i < attempts; i++) {
try {
const res = await apiFetch(url);
if (res.status >= 500) throw new Error(`upstream_${res.status}`);
return res;
} catch (err) {
last = err;
await new Promise((r) => setTimeout(r, 50 * 2 ** i));
}
}
throw last;
}
Choosing defaults for a platform team
- Ban per-request dispatchers in lint.
- Export one named agent for internal mesh, one for public egress with stricter timeouts.
- Migrate Got call sites that only use
got.get→fetch+ shared agent. - Keep Got where
beforeRetry/ pagination hooks are load-bearing — until replaced deliberately.
Closing checklist
✅ Dos
– ✅ Share one undici Agent/Pool per trust domain
– ✅ Benchmark with real TLS and multiple hosts
– ✅ Cap connections to protect upstreams
– ✅ Make retries explicit and method-aware
– ✅ Track pool stats / connect errors in metrics
❌ Don’ts
– ❌ Don’t trust localhost HTTP benchmarks for library choice
– ❌ Don’t enable pipelining blindly against unknown peers
– ❌ Don’t auto-retry non-idempotent methods
– ❌ Don’t create Got/Agent instances in hot paths
– ❌ Don’t ignore DNS + TLS in p99 budgets
Related reading
- HTTP/2 in Node Behind ALB: Multiplexing Pitfalls That Reset Streams
- Node SDK Generation: OpenAPI-Driven Clients LLMs Cannot Hallucinate
- Lambda + Bedrock: Stream Tokens Without API Gateway Caps
- Node.js Event Loop Lag: Catch P99 Stalls
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: Node Blob and File APIs: Efficient Multipart Upload Paths to S3 - CheatCoders