← Home Türkçe →
Blog · English

Cluster and Worker threads on Express

Same Express app, same two routes. Only the concurrency machinery underneath changed, and the light endpoint went from 2.9 to 82 requests per second while a heavy CPU task was running.

Watch the event loop. Two ways of running the same Express app.
Blue squares are light requests. On the plain lane, one orange block occupies the only worker, so light requests queue up. The second half runs two processes side by side: light requests flow through both, and heavy CPU work drops to a shared worker thread, then returns to the loop when done.
28×
light endpoint throughput while heavy work ran, plain vs cluster + worker threads
34×
better p99 latency of the light endpoint during the same window
2
vCPU cores the whole test used. The same two everywhere

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:

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.

Worker threads Heavy CPU work offloaded to threads. Latency stays low, but HTTP runs on one core. cluster + wt Cores for HTTP, threads for the heavy handlers. The production shape. plain Fine while every handler is fast. One heavy endpoint stalls the whole server. cluster More processes, more cores, roughly 2× HTTP throughput. Each loop is still blocked by heavy work. heavy CPU work in routes no heavy CPU work in routes one core is enough need more cores

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”.

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.

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)plainclusterWorker threadscluster + wt
/fast throughput (RPS)2.95.159.681.8
/fast p99 (ms)162710498548
/fast p50 (ms)6193212922
/slow throughput (RPS)2.75.43.52.7
/fast throughput (fast-only)78.8136.682.3134.7
CPU (mixed, % of all cores)1009919699
RSS idle (MB)6512886151

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.

The honest summary
plain is correct as long as nothing heavy ever hits the loop. One heavy endpoint breaks it. cluster only is right when the workload is I/O-bound and you have spare cores. It is useless against blocking CPU work. worker_threads only is right when the loop is getting blocked but one core is enough for HTTP. cluster + worker_threads is the production shape: cores for HTTP, a small pool for the heavy handlers.

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:

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.