Skip to content

fetch(): sequential keep-alive loopback requests ~45x slower than npm undici 7.16 on the SAME runtime (regression window 7.17-7.29) #5731

Description

@zerodeveloper1601

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:

  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

// 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions