Skip to content

Performance ​

In short, the npm package against Node's built-in fetch():

  • Level one request at a time (100% to 104%), and ahead with many requests in flight (102% to 116% since 2.2.0) and with large binary bodies (112% to 133%).
  • Steadier under sustained load: at 15,000 requests per second in an independent benchmark, a p99 of 11.2 ms against 450 ms, and 202 MB of memory against 635 MB (one run; in one of our two harnesses its tail was worse).
  • Behind on concurrent HTTPS (92%), and in our harness on 0.6 MB JSON (95%).
  • In browsers, rust-fetch uses fetch(), so it can't be faster than it; the difference is a fraction of a millisecond per request.

The largest gains come from the native transport, which isn't in the npm package yet.

About these numbers. All results are from local servers over loopback, so they compare each client's own overhead, not network performance. Percentages are relative throughput (requests per second), not latency. There are two sources: our measurements (Apple M1 Pro, Node.js 22.13) of both transports next to fetch() and undici.request(), and an independent benchmark of the published 2.1.0 and 2.2.0 against fetch() and axios (Apple M4 Pro, Node.js 22.15). See Reading these results before relying on a number.

The npm package today ​

The published node:http transport against Node.js fetch(), as measured with rust-fetch 2.1.x. Later releases haven't been re-run against fetch() and axios in these harnesses, except the 2026-10-25 rerun of the p99 stall; the CI benchmark checks each pull request against the previous version instead. The independent column is summarized from the full report below; ours from the all-client tables:

WorkloadIndependent benchmark (2.1.0, M4 Pro)Our measurements (M1 Pro)
Steady 15,000 requests/sp99 11.2 ms against 450 ms; slowest request 64 ms against 1.5 s; 202 MB against 635 MB of memory (one run)Independent harness: kept up (14,981/s against 13,026/s); p50 6.3 ms against 449 ms; p99 565 ms against 920 ms¹; event-loop delay 9.8 ms against 310 ms. Our harness: 12,955/s against 11,557/s, but a worse p99 (1.6 s against 1.2 s)
50 to 500 requests in flight105% to 116% throughput at every level107% to 114%, with a lower p99 at every level
Overload (offered 40,000 and 50,000 requests/s)Found a port-exhaustion bug in 2.1.0, fixed in 2.1.11.7× and 1.4× the requests delivered
8 MB binary bodies115% (faster in 10 of 10 runs)133%
0.6 MB JSON105% (faster in 10 of 10 runs)95%
Small JSON, one at a time100%102%
Small JSON, 50 at a time91%105% (500 at a time: 112%)
HTTPS small JSON, 50 at a timenot tested92%

¹ The 565 ms p99 no longer reproduces: 19.6 ms on node:http and 4.7 ms on native in a 2026-10-25 rerun.

The same independent harness later re-tested 2.2.0 (below): ahead of fetch() in all four throughput workloads (102% to 112%), including 50 requests at a time, where 2.1.0 was at 91%, and at an offered 50,000 requests/s it delivered 1.3 times fetch()'s requests with no errors and a third less memory.

Independent benchmark of rust-fetch 2.2.0 ​

aakjp/stress-test re-ran its benchmark on rust-fetch 2.2.0 (node:http transport) against Node's fetch() and axios 1.20, on Apple silicon with Node.js 22.15, against a local HTTP/1.1 server. The report has its method and raw data.

Throughput, requests per second, medians of 10 runs:

Workloadfetch()rust-fetch 2.2.0axios (default)rust-fetch vs fetch()
Small JSON, one at a time10,00210,3897,831104% (faster in 8 of 10 runs)
Small JSON, 50 at a time14,82115,18811,037102% (6 of 10)
Large JSON (0.6 MB)449473436105% (10 of 10)
Binary (8 MB)188210199112% (10 of 10)

Against fetch() in the same runs, 2.1.0 had scored 100%, 91%, 105% and 115%: the clear change is 50 requests at a time, from 91% to 102%. The report also compares 2.2.0's raw numbers with 2.1.0's (+4.5% to +7.3%), but fetch()'s own numbers moved by up to 9% between those runs, so we read the ratios.

Overload: an offered 50,000 requests/s for 60 s:

ClientDeliveredp50p99ErrorsPeak memory
rust-fetch 2.2.021,341/s461 ms505 ms0604 MB
fetch()16,158/s352 ms832 ms2,166913 MB
axios (keep-alive)13,763/s716 ms786 ms0754 MB
axios (default)3,113/s2.6 s10.5 s149,749912 MB

The improvement over 2.1.0 at this load (3,493/s with 172,942 errors) comes from the bounded connection pool released in 2.1.1, after the first report found the port exhaustion; 2.2.0 includes it. The report's verification suite also checked 2.2.0's new features (typed requests, upload progress, WebSockets with reconnection and typed errors): 19 of 19 checks passed, and 2.1.0 code ran unchanged.

Independent benchmark of rust-fetch 2.1.0 ​

aakjp/stress-test compared the published client, rust-fetch 2.1.0 (node:http transport), with Node's built-in fetch() and axios 1.20, on an Apple M4 Pro with Node.js 22.15, against a local HTTP/1.1 server without TLS or compression. The full report has its method and raw data.

Throughput, requests per second, median of 10 runs:

Workloadfetch()rust-fetchaxiosrust-fetch vs fetch()
Small JSON, one at a time9,6499,6807,497100%
Small JSON, 50 at a time15,52914,1919,90291%
Large JSON (0.6 MB)426446407105% (faster in 10 of 10 runs)
Binary (8 MB)174201188115% (faster in 10 of 10 runs)

With each client in its own process and a constant number of requests in flight, rust-fetch was ahead of fetch() at every level (one run per level):

Requests in flight50100200500
fetch()22,80022,39021,18119,019
rust-fetch23,97723,88223,45422,064
axios (default)14,73714,51913,44912,461

A steady 15,000 requests per second for 60 seconds (one run):

ClientDeliveredErrorsp50p99SlowestPeak memory
rust-fetch15,001/s04.7 ms11.2 ms64 ms202 MB
fetch()14,944/s03.1 ms450 ms1,545 ms635 MB
axios (keep-alive agent, 128 sockets)14,578/s0669 ms718 ms731 ms761 MB
axios (default)3,292/s165,4402.4 s13 s14 s843 MB

fetch() had the fastest median, but its slowest requests took up to 1.5 s, and its tail varied tenfold between runs. axios with its default configuration ran out of local ports: Node's default agent keeps connections alive (since Node.js 19) but sets no socket limit, so under overload it kept opening new ones. The keep-alive axios row used an agent capped at 128 sockets, against rust-fetch's 256 per origin, so it compares connection limits as well as clients. The report's 15,000 requests per second was 89% of what its server could handle.

Overload at 50,000 requests per second, two to three times what any client could sustain on the M4 Pro, found a bug: rust-fetch 2.1.0 opened a new connection for every waiting request, ran out of local ports and delivered 3.2k requests per second, while fetch() delivered 14.4k. 2.1.1 fixed it with a pool of up to 256 connections per origin, so extra requests wait instead. In our re-run of that test on an Apple M1 Pro (Node.js 22.13), rust-fetch went from 2.7k requests per second and 43.5k errors to 18.9k and none.

The report tested three clients on one machine. It didn't include undici.request(), got or ky.

The native transport (not in the npm package yet) ​

The native Node.js transport against Node.js fetch(), from our measurements (Apple M1 Pro, Node.js 22.13). The npm package doesn't include it yet; it has to be built from source.

WorkloadNative transport vs fetch()
HTTPS, 50 concurrent small-JSON requests1.95× throughput
500 concurrent small-JSON requests1.89× throughput
50 concurrent small-JSON requests1.71× throughput
8 MB binary transfers1.61× throughput
Offered 40,000 requests/s (overload)2.9× requests delivered
50 to 500 requests in flight, independent harness1.6× to 1.7× throughput, about half the p99 at every level
Event loop at 15,000 requests/s, independent harness1.6 ms p95 delay against 310 ms, leaving the event loop free for your application
Steady 15,000 requests/s, our harnessKept up (14,992/s against 11,557/s), about 13× lower p99 (91 ms against 1.2 s) and about 4× less memory (195 MB against 782 MB)
Steady 15,000 requests/s, independent harnessKept up (14,991/s against 13,026/s); p99 568 ms against 920 ms; 541 MB against 903 MB
Large JSON, 0.6 MB1.02×, even
Sequential small-JSON requests0.94×, about 6% slower (before 2.4.0; see below)

Since 2.4.0 the native transport runs on a single Tokio worker thread, with large compressed bodies decoded on a separate blocking pool. Each request is a short burst of work, so more workers mostly added cross-thread wake-ups. Measured against the previous build in fresh processes over 5 interleaved rounds (npm run bench:compare): sequential small JSON +7.3%, now level with the node:http transport (11,220 against 11,126 requests/s); 8 MB bodies +5.5%; 50 concurrent unchanged; and at an offered 40,000 requests/s, +9% delivered (34,827 against 31,970/s) with a lower p99 (281 against 316 ms). The table above predates this change.

Why concurrency is where it wins. With the node:http transport, connection handling, HTTP parsing and decompression share the JavaScript event loop with your application. The native transport runs them on its own threads, leaving the event loop free to build requests and decode responses. With one request at a time there's little to overlap, and the hand-off between threads costs a little. How much you gain depends on your hardware, payloads and concurrency.

Where it doesn't win: sequential requests were about 6% slower than with fetch() and the node:http transport before 2.4.0, and are now level with the node:http transport; large JSON is effectively even (304 against 299 requests per second), and both parse the JSON in JavaScript; and under heavy overload it used about as much memory as fetch() (796 MB against 781 MB).

Our measurements: every client ​

The tables behind both summaries: Node's fetch(), undici.request() and both rust-fetch transports, measured together on an Apple M1 Pro with Node.js 22.13.

Throughput: all clients ​

All four clients ran in the same rounds, in rotating order, against a server in a separate process (HTTP/1.1, and HTTPS where noted). Requests per second, median of 7 rounds:

Workloadfetch()undici.request()rust-fetch, node:httprust-fetch, nativeNative vs fetch()
Small JSON, sequential10,46815,34610,6919,85794% (6% slower)
Small JSON, 50 concurrent18,01942,26018,91630,874171%
Small JSON, 500 concurrent15,41938,17117,28529,209189%
HTTPS small JSON, 50 concurrent15,63930,54614,44430,452195%
Large JSON, 0.6 MB299325283304102%
Binary, 8 MB201301267322161%

In the three concurrent workloads, the native transport's p99 latency was less than half of fetch()'s (50 concurrent: 2.41 ms against 5.38 ms; 500 concurrent: 23.45 ms against 58.64 ms; HTTPS: 2.44 ms against 6.46 ms). For large JSON it was higher (8.76 ms against 5.39 ms).

Under load: all clients ​

Each client ran in its own process. These rows come from two different harnesses and aren't comparable with each other:

Testfetch()undici.request()rust-fetch, node:httprust-fetch, nativeNative vs fetch()
Offered 40,000 requests/s for 20 s, delivered (our harness)10,081/s37,687/s16,984/s29,502/s2.9×
Offered 50,000 requests/s for 60 s, delivered (aakjp/stress-test harness)12,421/snot tested17,828/s32,678/s2.6×

Concurrency sweep, a constant number of requests in flight for 10 s each (aakjp/stress-test harness), requests per second with p99 latency:

Requests in flightfetch()rust-fetch, node:httprust-fetch, nativeNative vs fetch()
5017,041 (6.8 ms)18,452 (6.1 ms)29,198 (2.4 ms)171%
10017,660 (10.2 ms)18,855 (9.3 ms)28,402 (5.1 ms)161%
20016,365 (24.3 ms)18,624 (14.8 ms)28,454 (11.3 ms)174%
50015,960 (47.3 ms)17,685 (38.7 ms)27,563 (24.6 ms)173%

At 40,000 requests per second, peak memory was 781 MB for fetch(), 244 MB for undici.request(), 567 MB for the node:http transport and 796 MB for the native transport, whose queued requests wait inside the addon.

A steady 15,000 requests per second, sent on a fixed schedule whether or not the client keeps up. No client had errors. Our harness (20 s, one server process):

ClientDeliveredp50p95p99Peak memory
fetch()11,557/s477 ms779 ms1.2 s782 MB
undici.request()14,992/s2.4 ms11 ms31 ms135 MB
rust-fetch, node:http12,955/s404 ms1.1 s1.6 s541 MB
rust-fetch, native14,992/s4.1 ms46 ms91 ms195 MB

The aakjp/stress-test harness (60 s, six server processes):

ClientDeliveredp50p95p99Peak memoryJS heap after the runEvent-loop delay (p95)
fetch()13,026/s449 ms491 ms920 ms903 MB479 MB310 ms
rust-fetch, node:http14,981/s6.3 ms118 ms565 ms548 MB82 MB9.8 ms
rust-fetch, native14,991/s3.5 ms4.8 ms568 ms541 MB23 MB1.6 ms

Event-loop delay is how late the harness's 10 ms scheduler ran: how long your own code would wait behind the HTTP client.

In both harnesses, the native transport sustained approximately the target rate and fetch() didn't. In that independent run, both rust-fetch transports showed a p99 of about 565 ms even though the native transport's p95 was 4.8 ms.

That stall no longer reproduces. The same harness (commit 2d6db73), the same scenario (a steady 15,000 requests per second for 60 s, six server processes), one machine (Apple silicon, Node.js 22.13), rerun on 2026-10-25:

rust-fetchTransportDeliveredp50p95p99SlowestErrors
2.3.0node:http15,001/s5.8 ms8.4 ms53 ms124 ms0
2.4.0node:http15,001/s5.7 ms8.0 ms19.7 ms100 ms0
2.5.2node:http15,001/s5.5 ms7.8 ms19.7 ms105 ms0
2.6.0node:http15,001/s5.4 ms7.6 ms19.6 ms105 ms0
2.6.0native15,002/s3.4 ms4.3 ms4.7 ms17 ms0

Even 2.3.0 no longer shows 565 ms here, so the original cause can't be pinned down with certainty. The drop from 2.3.0 to 2.4.0 matches 2.4.0's changes: at most 1,024 requests handed to a transport at once, and streaming moved onto node:http. Details are in #16.

Startup ​

How long until the first response, from a fresh Node.js process or a fresh page (npm run bench:cold; median of 7 samples, local server, Apple M1 Pro, Node.js 22.13). Import is importing the package, or init() in browsers; first request is the first get() of a small JSON response.

RuntimeClientImport / init()First requestTotal
Node.jsfetch()—21.9 ms21.9 ms
Node.jsrust-fetch, node:http transport18.3 ms9.3 ms27.6 ms
Node.jsrust-fetch, native transport20.2 ms5.3 ms25.5 ms
Chromiumrust-fetch, empty cache8.3 ms6.6 ms14.9 ms
Chromiumrust-fetch, cached2.4 ms1.3 ms3.7 ms
Firefoxrust-fetch, empty cache15.0 ms3.0 ms18.0 ms
Firefoxrust-fetch, cached13.0 ms3.0 ms16.0 ms
WebKitrust-fetch, empty cache10.0 ms5.0 ms15.0 ms
WebKitrust-fetch, cached7.0 ms3.0 ms10.0 ms
  • Node.js: about 13 ms of the import is Node.js loading node:http, which fetch() pays too, on its first call (it loads undici lazily). rust-fetch's own share is about 7 ms: compiling the WebAssembly module (about 1 ms, since V8 compiles functions on first use), its JS glue, and loading the native addon. All told, the first response arrives 4 to 6 ms later than with fetch().

  • Browsers: init() streams the module into the compiler as it downloads (WebAssembly.instantiateStreaming, which needs Content-Type: application/wasm). Chromium also caches the compiled code, so a returning visit costs a few milliseconds. The numbers above don't include download time. On a real network, start the download early by preloading the file from your page's <head>:

    html
    <link rel="preload" href="/assets/rust_fetch_bg.wasm" as="fetch" type="application/wasm" crossorigin>

    Use the URL your bundler gives the file. With Vite, for example, import wasmUrl from 'rust-fetch/rust_fetch_bg.wasm?url' returns it, and init({ module_or_path: wasmUrl }) then loads that same URL.

Reading these results ​

  • Throughput, latency and memory are separate questions. A client can deliver more requests per second with a worse tail, or the reverse; memory is the peak resident size of the client process.
  • fetch(), undici.request() and rust-fetch do different amounts of work. Node's fetch() implements the browser fetch() API on top of undici. undici.request() is undici's lower-level API, without WHATWG streams. rust-fetch adds retries, per-attempt timeouts, validation, typed errors and interceptors to every request. So undici.request() isn't an identical workload, and it was fastest on plain HTTP in these tests: 1.4 to 2.2 times the node:http transport's small-JSON throughput and 1.3 to 1.6 times the native transport's, while over HTTPS the native transport matched it.
  • Results vary with hardware, Node.js version, payload size, concurrency and the server setup. The node:http transport delivered 12,955 requests per second at an offered 15,000 in one harness and 14,981 in the other, so measure on your own hardware before relying on a number (how).

Released under the MIT or Apache-2.0 license.