fetch(), Request, Response and Headers are globals in Node, stable since Node 21 and implemented by undici 7,705 (nodejs/undici (https://github.com/nodejs/undici 7,705 ), 8.10.2 inside Node 26). The older http.request API underpins many libraries; write nothing new against it.
import { request, Agent, interceptors } from 'undici';
const res = await fetch('http://127.0.0.1:3002/books');
console.log('fetch ', res.status, res.headers.get('content-type'), await res.json());
const agent = new Agent({ connections: 2 }).compose(interceptors.retry({ maxRetries: 2 }));
const r2 = await request('http://127.0.0.1:3002/books', { dispatcher: agent });
console.log('undici ', r2.statusCode, await r2.body.json());
try {
await fetch('http://127.0.0.1:3002/slow', { signal: AbortSignal.timeout(300) });
} catch (err) {
console.log('timeout', err.name, '|', err.cause?.message ?? err.message);
}fetch 200 application/json { ok: true, port: 52109 }
undici 200 { ok: true, port: 52110 }
timeout TimeoutError | The operation was aborted due to timeoutThree habits keep a client healthy. Set a deadline: fetch has no default timeout, so a hung peer holds your handler forever. Drain the body even when you do not want it (await res.body?.cancel()), or the response keeps its socket out of the pool. Share one dispatcher: undici pools per origin, so an Agent per request throws away connections you paid handshakes for, and compose is where retry, redirect and cache policy belongs.
Two facts cost people hours: fetch rejects only on network failure, so a 404 is a fulfilled promise — check res.ok yourself; and undici.request, the one for streaming bodies, does not follow redirects unless you compose interceptors.redirect.