Performance
How 2.1.0, 2.2.0 and the node-tls-client binary run requests, and how their speed, CPU and memory compare.
How requests run
The TLS work is done by tls-client, a native library. With its official binary, a request is one call that returns only when the response has arrived, so a thread waits on every request in flight. What changed between versions is that thread:
| 2.1.0 | 2.2.0 | 2.2.0 + node-tls-client binary | |
|---|---|---|---|
| Each request in flight holds | a worker thread, each with its own copy of the JavaScript engine | a thread from Node's libuv threadpool | no thread |
| Requests running at once | CPU cores × 2, fixed | UV_THREADPOOL_SIZE: CPU cores × 2 by default, up to 1024 | every request you start, as with node-fetch |
- 2.1.0 passed every request and response between threads and ran a JavaScript engine per worker. That is where most of its CPU and memory went.
- 2.2.0 makes the same call from the main thread, and Node runs it on a threadpool thread. Raise the limit with
UV_THREADPOOL_SIZE(see Concurrency); 2.1.0 had no setting for it. - The node-tls-client binary is our build of tls-client. Its call returns at once, and the library calls back when responses arrive, so requests in flight hold no thread. Opt in with
initTLS({ binary: "node-tls-client" }).
Benchmarks
The three columns use the same browser profile and options. node-fetch 2.7.0 is in the table for reference.
Requests in flight
Against a server that answers in 50 ms. 2.1.0 and 2.2.0 both run 20 requests at once on the test machine, so they stop at about 385 requests per second and their lines overlap. With the node-tls-client binary, throughput keeps growing with requests in flight.
Bursts
Over HTTP/1.1, each request opens its own connection. Over HTTP/2, the requests share one connection, so the node-tls-client binary finishes in 0.10 s instead of 0.47 s. node-fetch doesn't support HTTP/2, so the table below compares it on HTTP/1.1 only. In two of three HTTP/1.1 runs, node-fetch lost connections (401 of 6,000 requests failed with ETIMEDOUT) and took about 8 s; its clean run took 0.8 s. The test server's queue of waiting connections holds 128 on macOS by default, and node-fetch overflowed it; the tls-client builds never did.
CPU and memory
2.1.0 used the most CPU at moderate load: with 16 requests in flight it kept about 3 CPU cores busy (9.8 ms per request, against 0.56 ms in 2.2.0), because its pool stopped idle worker threads and started new ones as requests came and went.
With the node-tls-client binary, memory grows with requests in flight instead of stopping at the thread count: see the burst rows in the table below, and Memory.
All results
Medians of three runs.
| Scenario | 2.1.0 | 2.2.0 | 2.2.0 + node-tls-client binary | node-fetch 2.7.0 |
|---|---|---|---|---|
| 1 in flight, 50 ms server: requests/s | 19 req/s | 19 req/s | 19 req/s | 19 req/s |
| 16 in flight, 50 ms server: requests/s | 305 req/s | 309 req/s | 306 req/s | 309 req/s |
| 64 in flight, 50 ms server: requests/s | 377 req/s | 385 req/s | 1,207 req/s | 1,228 req/s |
| 256 in flight, 50 ms server: requests/s | 383 req/s | 385 req/s | 4,735 req/s | 4,597 req/s |
| 1,024 in flight, 50 ms server: requests/s | 383 req/s | 384 req/s | 15,432 req/s | 14,339 req/s |
| 5,000 at once, 50 ms server: time to finish | 13.1 s | 13.0 s | 0.26 s | 0.43 s |
| 5,000 at once, 50 ms server: peak memory | 468 MB | 131 MB | 349 MB | 278 MB |
| 16 in flight, 50 ms server: CPU per request | 9,833 µs | 564 µs | 496 µs | 418 µs |
| 64 in flight, 50 ms server: CPU per request | 1,327 µs | 494 µs | 314 µs | 235 µs |
| 64 in flight, 50 ms server: peak memory | 372 MB | 86 MB | 89 MB | 84 MB |
| One at a time, instant server: median latency | 1.5 ms | 1.4 ms | 1.5 ms | 1.4 ms |
| 64 in flight, instant server: requests/s | 10,068 req/s | 10,302 req/s | 31,343 req/s | 29,790 req/s |
| 64 in flight, instant server: CPU per request | 364 µs | 240 µs | 125 µs | 47 µs |
| HTTPS (HTTP/1.1), 64 in flight, 50 ms server: requests/s | 383 req/s | 382 req/s | 1,219 req/s | 1,226 req/s |
| HTTPS (HTTP/1.1), 2,000 at once, 50 ms server: time to finish | 5.21 s | 5.20 s | 0.47 s | 8.08 s (401 of 6,000 requests failed) |
| HTTPS (HTTP/2), 64 in flight, 50 ms server: requests/s | 378 req/s | 382 req/s | 1,202 req/s | not supported |
| HTTPS (HTTP/2), 2,000 at once, 50 ms server: time to finish | 5.26 s | 5.21 s | 0.10 s | not supported |
How this was measured
- Apple M5 (10 cores, 16 GB), macOS 26.6, Node.js 26.7.0.
- A local server in a separate process answered each request after 50 ms (or at once, for the CPU and latency rows), over HTTP/1.1. The HTTPS rows ran twice: over HTTP/1.1 with every client, and over HTTP/2 without node-fetch, which doesn't support it.
- Each run was a fresh process with one
Sessionand each client's default settings: 20 workers in 2.1.0, andUV_THREADPOOL_SIZE20 in 2.2.0. Three runs per cell, clients in rotating order, with leftover sockets drained between runs. - Native libraries: 2.1.0 and 2.2.0's default binary used tls-client 1.16.0; the node-tls-client binary was built from tls-client 1.18.0.
- The harness is
bench/in the repository.