How we tested
Node is single-threaded, and this benchmark is about what happens when a CPU-bound request meets the event loop. We ran everything on a dedicated Hetzner 2-core VPS (AMD EPYC, Ubuntu 26.04) with Node 26.5.0 and Express 5.2.1. Two endpoints:
/fast:fibonacci(30), roughly 12 ms of pure CPU. The normal request./slow:fibonacci(37), roughly 380 ms of pure CPU. The heavy, blocking one.
Each configuration was measured the same way: autocannon with 2 connections per endpoint, a 15-second warm-up, then a 30-second fast-only phase, then a 75-second mixed phase where both endpoints were hammered simultaneously. Percentiles are computed exactly from every individual response time, and the server samples its own CPU and RSS every 200 ms.
In a minimal two-endpoint benchmark, how you handle concurrency changed the result by 28×.
Concurrency, and what it does to Express
One thread, two kinds of work, and where the second one breaks everything.
Node runs JavaScript on one thread. When an Express route hits a database, the query goes to the operating system and the loop is free to handle other requests. That is the asynchronous model that lets a single process serve thousands of requests. The loop only stops being free when something does synchronous CPU work.
That is exactly the situation the animation above simulates. A handler that burns 380 ms of CPU does not yield. Every request behind it waits in line, and a single /slow request every few hundred milliseconds is enough to keep the loop permanently busy.
There is a second, quieter limit: a single process can only use one core. So concurrency in Node splits into two independent questions, and each configuration from the benchmark answers one of them:
Where each configuration belongs
Two questions, four setups. Hover a quadrant for the verdict.
Every bar in the charts below is the same Express app. The only difference is which machinery sits in the right quadrant.
Cluster: when should you use it?
Cluster forks independent Node processes that share one port. Each worker has its own memory, its own heap, its own event loop, and its own core. It is the standard production answer for “use more cores for HTTP”.
- Use it when your app is mostly I/O-bound (databases, external APIs) and you need more than one core. This is the default for any production Node server.
- Understand that each process duplicates memory, and in-memory state (sessions, caches) is per-worker. Shared state lives in Redis or a database.
- What it does not fix: a blocking handler. Each worker still runs one event loop, so cluster scales throughput without protecting latency, as the p99 chart below shows.
Worker threads: when should you use it?
Worker threads live inside the process. Same memory space, separate V8 isolate, and they talk to the main thread through messages, exactly like Web Workers in a browser. The main thread keeps serving HTTP. The workers run the CPU-heavy code and post the result back.
The critical thing to understand: worker_threads does not make HTTP itself faster. The event loop still handles every request, and HTTP still runs on one core. A single-process worker-thread app scales throughput exactly like the plain one, as the fast-only chart showed. What worker threads do is keep the loop free. That shows up somewhere different: tail latency.
Read the p99 axis. In plain, the slowest 1% of light requests waited 1.6 seconds, queued behind heavy work. cluster helps a little, but each worker’s loop is still blocked one at a time. worker_threads drops it to 85 ms. Combined with cluster, it is 48 ms.
- Use it when a handler does real CPU work: image resizing, crypto, video encoding, parsing huge payloads. Offload that, and the event loop keeps serving everyone else.
- Understand that a small pool is spawned up front instead of a new worker per request. Thread creation is not free.
- What it does not fix: more cores for HTTP. For that you still need cluster.
Cluster adds cores. worker_threads protects the loop. They solve different problems, and neither replaces the other.
The scenarios, side by side
One table, four configurations, every number from the benchmark.
Mixed means the heavy endpoint ran for the whole window. Fast-only means it did not.
| metric (mixed phase) | plain | cluster | Worker threads | cluster + wt |
|---|---|---|---|---|
| /fast throughput (RPS) | 2.9 | 5.1 | 59.6 | 81.8 |
| /fast p99 (ms) | 1627 | 1049 | 85 | 48 |
| /fast p50 (ms) | 619 | 321 | 29 | 22 |
| /slow throughput (RPS) | 2.7 | 5.4 | 3.5 | 2.7 |
| /fast throughput (fast-only) | 78.8 | 136.6 | 82.3 | 134.7 |
| CPU (mixed, % of all cores) | 100 | 99 | 196 | 99 |
| RSS idle (MB) | 65 | 128 | 86 | 151 |
Two numbers deserve attention. First, /slow throughput is flat everywhere, about 2.7 to 5.4 RPS, because the workload is CPU-bound and there are two cores. worker_threads does not make heavy work faster. It makes heavy work stop punishing everyone else. Second, notice where cluster’s cost lives: idle RSS doubles, 128 MB against 65 MB, because you now run two full Node processes. Threads, sharing one process, add only about 20 MB.
Conclusion
In the previous post we saw that Express is not slow. Your Node version is. The same lesson repeats here in a different form: Express is not slow. Your concurrency setup is. A single-process, single-loop Express app is perfectly fast until one handler decides to burn 380 ms of CPU. Then the whole server stalls, and no amount of request throughput elsewhere fixes it.
The fix is not a different framework. It is knowing what each tool does:
- Use cluster to spread HTTP across cores. This is the default for production Node.
- Use worker_threads, via a small pool, for anything a handler does synchronously that takes more than a few milliseconds of CPU.
- Use both. That is what production Express with a bcrypt route looks like.
In a minimal two-endpoint benchmark, that combination moved the light endpoint from 2.9 to 81.8 requests per second, 28×, and cut its p99 latency from 1.6 seconds to 48 ms. No route changed. No middleware changed. Only the machinery underneath.
Measured with Express 5.2.1 on Node.js 26.5.0. Two-core AMD EPYC Hetzner VPS (Ubuntu 26.04). autocannon, 2 connections per endpoint. 15 s warm-up. 30 s fast-only + 75 s mixed phases. Percentiles computed from per-request latencies. CPU/RSS self-sampled at 200 ms. Workload: fibonacci(30) ≈ 12 ms and fibonacci(37) ≈ 380 ms of synchronous CPU.