Version
Regression window pinned by same-runtime A/B (table below):
- Slow: bundled undici 8.10.0 (Node 26.8.1) AND npm undici 7.29.0 standalone
- Fast: npm undici 7.16.0 standalone — on the very same Node 26.8.1 runtime
- Healthy baseline: Node 24.11.1 (bundled undici 7.16)
Platform
win32-x64 (Windows 11 Pro build 26200)
Current behavior
Sequential await fetch() to a plain http.Server on 127.0.0.1 collapses from ~2,500–3,500 rps
(undici 7.16) to ~70–80 rps (undici 7.29+/8.10) — ~45×. Keep-alive is NOT broken: server-side
connection counting shows one pooled socket (no per-request reconnects), and core http.get over the
same server measures ~1,900–2,050 rps on the affected runtime — the regression is inside undici's
fetch, not the pool or the parser.
The matrix (same machine, same server, same driver, ?r.arrayBuffer() consumed per request)
| Client |
Node 24.11.1 (bundled undici 7.16) |
Node 26.8.1 (bundled undici 8.10) |
globalThis.fetch |
2,504–3,456 rps (healthy) |
75–81 rps (regressed) |
npm undici@7.16.0 fetch |
3,567 rps |
3,440 rps — FAST on the "slow" runtime |
npm undici@7.29.0 fetch |
— |
67 rps — regressed |
Two consequences we believe are load-bearing for triage:
- undici 7.16 standalone is fast ON Node 26.8.1 — the regression is entirely inside undici's
7.17 → 7.29 window, NOT an interaction with Node 26 core (same runtime, same machine, 45× apart).
- 7.29.0 is already fully regressed (equal to 8.10) — the change landed by 7.29, not in 8.x.
Expected behavior
Sequential awaited fetch() to loopback should stay within an order of magnitude of 7.16. Even a
2–3× sequential-path penalty would be unremarkable; ~45× makes any loopback probe loop (health
checks, local control-plane polling) dominate a process's wall-clock after a runtime or undici bump.
Minimal reproduction
// repro.mjs — node repro.mjs (no deps). Prints rps + the server's connection count.
import { createServer } from "node:http";
const connections = [];
const server = createServer((_req, res) => res.end("ok"));
server.on("connection", (c) => connections.push(c));
server.keepAliveTimeout = 60_000;
await new Promise((res) => server.listen(0, "127.0.0.1", res));
const url = `http://127.0.0.1:${server.address().port}/`;
let n = 0;
const t0 = performance.now();
const warm = performance.now() + 25; // discard the cold-start window
while (performance.now() - t0 < 1000) {
if (performance.now() < warm) continue;
const r = await fetch(url); // SEQUENTIAL — the shape that exposes the regression
await r.arrayBuffer();
n++;
}
console.log(`node ${process.version}, bundled undici ${process.versions.undici}`);
console.log(`sequential loopback fetch: ${(n / ((performance.now() - t0) / 1000)).toFixed(0)} rps, connections: ${connections.length} (1 = keep-alive intact)`);
server.close();
To pin the undici side on one machine: npm i undici@7.16 vs npm i undici@7.29 in a scratch dir
and const { fetch } = require("undici") in the same loop — 7.16 is fast, 7.29 is slow, same runtime.
Observed on this machine:
node v24.11.1 (bundled 7.16) global fetch → 2,504–3,456 rps, 1 connection
node v26.8.1 (bundled 8.10) global fetch → 75–81 rps, 1 connection
node v26.8.1 + npm undici@7.16 → 3,440 rps
node v26.8.1 + npm undici@7.29.0 → 67 rps
Additional context
- The regression window is narrow (7.17–7.29) per the same-runtime A/B above — a git bisect
between those tags on fetch's sequential path should find it; happy to test candidate patches.
- Concurrent/batched workloads do NOT surface it — the sequential awaited shape is load-bearing
for the repro.
- Real-world trigger: any loopback probe loop after a runtime/undici upgrade. First observed as a
click-rate probe "feeling" ~45× slower; verified with server-side connection counting (pool fine)
before blaming the server. Downstream guard we ship: a self-relative fetch/http ratio oracle
(regressed ≈ 0.04, healthy ≈ 0.7+) that runs on every runtime bump.
Version
Regression window pinned by same-runtime A/B (table below):
Platform
Current behavior
Sequential
await fetch()to a plainhttp.Serveron127.0.0.1collapses from ~2,500–3,500 rps(undici 7.16) to ~70–80 rps (undici 7.29+/8.10) — ~45×. Keep-alive is NOT broken: server-side
connection counting shows one pooled socket (no per-request reconnects), and core
http.getover thesame server measures ~1,900–2,050 rps on the affected runtime — the regression is inside undici's
fetch, not the pool or the parser.The matrix (same machine, same server, same driver,
?r.arrayBuffer()consumed per request)globalThis.fetchundici@7.16.0fetchundici@7.29.0fetchTwo consequences we believe are load-bearing for triage:
7.17 → 7.29 window, NOT an interaction with Node 26 core (same runtime, same machine, 45× apart).
Expected behavior
Sequential awaited
fetch()to loopback should stay within an order of magnitude of 7.16. Even a2–3× sequential-path penalty would be unremarkable; ~45× makes any loopback probe loop (health
checks, local control-plane polling) dominate a process's wall-clock after a runtime or undici bump.
Minimal reproduction
To pin the undici side on one machine:
npm i undici@7.16vsnpm i undici@7.29in a scratch dirand
const { fetch } = require("undici")in the same loop — 7.16 is fast, 7.29 is slow, same runtime.Observed on this machine:
Additional context
between those tags on
fetch's sequential path should find it; happy to test candidate patches.for the repro.
click-rate probe "feeling" ~45× slower; verified with server-side connection counting (pool fine)
before blaming the server. Downstream guard we ship: a self-relative fetch/http ratio oracle
(regressed ≈ 0.04, healthy ≈ 0.7+) that runs on every runtime bump.