Plate 27
undici vs node-fetch: 31k vs 7.4k Req/s on Node 22
Aditya Challa6 min read
I load-tested both HTTP clients on this box against the same local keep-alive server: undici 8.11.2's request() handled about 31,000–36,000 requests per second, while node-fetch 3.3.2 handled about 7,200–7,400, roughly 4–5× fewer. undici's own fetch() landed near 13,000 req/s, but the full undici bundle cost 173,375 bytes gzip versus 26,012 bytes for node-fetch.
Short answer: on Node 22.19 or newer, use undici, and reach for request() on hot server-to-server paths. Keep node-fetch only for older Node versions or code that depends on its Node-stream body. No affiliate links in this post.
What I tested
- Machine: 8 vCPU Intel Xeon / 15 GB RAM shared Linux cloud box, tested 6 Oct 2026 about 02:30 IST.
- Versions: undici 8.11.2, node-fetch 3.3.2 (both the npm latest that day), Node 22.20.0 (whose built-in fetch is undici 6.21.2), esbuild 0.28.2 node ESM minify + gzip -9.
- Load: a local Node http server returning a 764-byte JSON body. 5,000 GET requests at 50 concurrent, JSON parsed and counted for parity, one warm-up round, median of 7 rounds, two processes with the client order reversed. undici used an Agent with 50 connections; node-fetch used http.Agent with keepAlive and 50 sockets; Node's global fetch used its defaults. Scripts and JSON in /workspace/bench-undici-node-fetch/.
- Findings: request() 35,626 / 30,934 req/s, undici fetch() 13,321 / 13,039, node-fetch 7,176 / 7,436, global fetch 7,911 / 10,827. Bundle (minified / gzip): undici 553,516 / 173,375 B from 111 input files; node-fetch 92,583 / 26,012 B from 22. On disk: undici about 2.5 MB with no dependencies; node-fetch plus its 5 dependencies about 9.4 MB, most of it web-streams-polyfill.
- One surprise: neither undici 8's Response nor node-fetch's passed an instanceof check against Node 22's global Response, because the built-in fetch ships its own older undici copy.
- Not tested: HTTP/2, TLS, proxies (ProxyAgent), large uploads or streaming downloads, retries and interceptors, real network latency, Bun or Deno, and Node 18/20, which undici 8 does not support.
How much faster is undici on Node 22?
| 5,000 GETs, 50 concurrent (median of 7) | Run 1 req/s | Run 2 req/s | Run 1 / Run 2 ms |
|---|---|---|---|
| undici request() | 35,626 | 30,934 | 140.3 / 161.6 |
| undici fetch() | 13,321 | 13,039 | 375.3 / 383.5 |
| Node 22 global fetch (undici 6.21.2) | 7,911 | 10,827 | 632.0 / 461.8 |
| node-fetch 3.3.2 | 7,176 | 7,436 | 696.8 / 672.4 |
Run 1 went request, fetch, node-fetch, global; run 2 reversed it. request() beat node-fetch by 4.2–5.0×, and undici fetch() beat it by about 1.8×. The global fetch number moved a lot with order, so treat it as a range. On loopback this is mostly client overhead; over a real network, latency shrinks the gap. Why both clients were given keep-alive pools is shown with nginx numbers in HTTP keep-alive vs Connection: close. If you mock these calls in tests, nock vs msw measured how mocking cost differs between fetch and Node's http module.
What does undici cost in bundle size?
| Build (esbuild node minify, gzip -9) | Minified | gzip | Input files |
|---|---|---|---|
| undici 8.11.2 (fetch, request, Agent) | 553,516 B | 173,375 B | 111 |
| node-fetch 3.3.2 | 92,583 B | 26,012 B | 22 |
undici is about 6.7× bigger gzipped when you bundle it. That matters for serverless cold starts or a single-file CLI, and not at all if you rely on Node's built-in fetch and bundle nothing. Track it the same way as in size-limit vs bundlesize. If you want a thin wrapper on top of fetch instead, compare ofetch vs ky and ky vs axios.
Do they behave the same?
| Check (local server) | node-fetch 3.3.2 | undici fetch() | undici request() |
|---|---|---|---|
| Body type | Node PassThrough stream | web ReadableStream | BodyReadable (Node stream) |
| 404 response | status 404, ok false, no throw | status 404, ok false, no throw | statusCode 404, no throw |
| 302 redirect | followed, redirected true | followed, redirected true | returns 302, not followed by default |
| instanceof global Response | false | false | not a Response |
| Node support | 12.20+ (ESM only) | 22.19+ | 22.19+ |
The redirect row is the one that bites people moving from fetch to request(): you get the raw 302 and have to follow it yourself or add undici's redirect interceptor. node-fetch's body is a Node stream, so code that pipes res.body into fs streams works as is; undici fetch() gives you a web stream instead. URL building around either client is covered in ufo vs URL, and safe JSON parsing of responses in destr vs superjson.
Which one should you pick?
| Situation | My pick |
|---|---|
| Hot server-to-server calls on Node 22.19+ | undici request() |
| Standard fetch API, newer than the built-in copy | undici fetch() |
| Just need fetch on Node 22 with no dependency | Node's global fetch |
| Stuck on Node 18 or 20, or need a Node-stream body | node-fetch |
| Bundled CLI or serverless where size beats speed | node-fetch or the global fetch |
Bottom line: undici 8.11.2's request() moved about 31,000–36,000 req/s on this box versus about 7,200–7,400 for node-fetch 3.3.2, and even undici's fetch() was about 1.8× faster, at the price of a 173 KB gzip bundle versus 26 KB. Who should not switch: projects pinned below Node 22.19, or code built around node-fetch's Node-stream body and automatic redirects that would need rework for request(). Who should not stay on node-fetch: new Node 22 services, where the global fetch or undici covers the same API without a polyfill.
How this was made: I installed both packages on the ShopperCove box, ran a local keep-alive HTTP server, fired 5,000 requests at 50 concurrent through each client in two order-reversed processes, bundled each client with esbuild, and checked body types, 404 handling and redirects against the same server. The JSON results sit next to the scripts. The write-up was drafted with AI help and checked against that output.
Sources
- https://github.com/nodejs/undici
- https://undici.nodejs.org/
- https://github.com/node-fetch/node-fetch
- https://www.npmjs.com/package/undici
- https://www.npmjs.com/package/node-fetch
- https://nodejs.org/api/globals.html
Related
- https://www.shoppercove.com/blog/ofetch-vs-ky
- https://www.shoppercove.com/blog/ky-vs-axios
- https://www.shoppercove.com/blog/msw-vs-nock
- https://www.shoppercove.com/blog/http-keepalive-vs-connection-close-lab
- https://www.shoppercove.com/blog/size-limit-vs-bundlesize
- https://www.shoppercove.com/blog/ufo-vs-url
- https://www.shoppercove.com/blog/destr-vs-superjson
- https://www.shoppercove.com/blog/tinyexec-vs-execa
Lab evidence
What I found running this
Hands-on on ShopperCove box 6 Oct 2026 ~02:30 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 22.20.0, built-in fetch = undici 6.21.2). undici 8.11.2 vs node-fetch 3.3.2. Load: local keep-alive http server, 764 B JSON, 5,000 GETs x 50 concurrent, warm-up + 7-round median, order-reversed (req/s): request() 35,626/30,934; undici fetch() 13,321/13,039; node-fetch 7,176/7,436; global fetch 7,911/10,827. esbuild 0.28.2 node minify + gzip -9: 553,516/173,375 B (111 inputs) vs 92,583/26,012 B (22). Disk ~2.5 MB 0 deps vs ~9.4 MB with 5 deps. Behavior: request() does not follow 302 by default; both fetches follow; no client throws on 404; node-fetch body is a Node PassThrough, undici fetch a web ReadableStream. undici 8 needs Node >=22.19. Not tested: HTTP/2, TLS, ProxyAgent, streaming, interceptors, real network, Bun/Deno. No affiliate.