### 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 ```text 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: 1. 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). 2. **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 ```js // 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: ```text 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.