6 min read
undici is much faster than node-fetch on Node 22: in a local keep-alive test, undici 8.11.2's request() handled about 31,000-36,000 requests per second and its fetch() about 13,000, against about 7,200-7,400 for node-fetch 3.3.2. Use undici on Node 22.19 or newer (request() for hot server-to-server calls), and keep node-fetch only for older Node versions, code that needs its Node-stream body, or bundles where its 26 KB gzip size beats undici's 173 KB.
| 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.
| 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.
| 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.
| 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.
Yes, in this test. undici request() beat node-fetch by 4.2-5.0x, and undici fetch() beat it by about 1.8x, on 5,000 GETs at 50 concurrent against a local server. Over a real network, latency narrows the gap.
No. request() returns the raw 302 by default, so you follow it yourself or add undici's redirect interceptor. Both node-fetch and undici fetch() follow redirects.
Node 22.19 or newer. node-fetch 3.3.2 supports Node 12.20+ (ESM only), which is the main reason to keep it on older runtimes.
Lab evidence
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.
Explore the product
Untested by us, and no traffic result is guaranteed. Check the live offer and refund terms first.