Node Fetch vs Got vs Undici: Throughput Under Real TLS Churn

Node Fetch vs Got vs Undici: Throughput Under Real TLS Churn

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/Pool with explicit connections, pipelining, and timeouts for high-QPS egress; use fetch for 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

  1. Ban per-request dispatchers in lint.
  2. Export one named agent for internal mesh, one for public egress with stricter timeouts.
  3. Migrate Got call sites that only use got.getfetch + shared agent.
  4. 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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

1 Comment

Leave a Reply