The Event Loop Is Not Multithreading: The Most Common Concurrency Misconception in Node.js
A persistent misconception in the Node.js world goes something like this: “Node can handle tens of thousands of concurrent connections, so it must be doing multithreading.” It isn’t. Node’s concurrency is built on a single-threaded event loop combined with non-blocking I/O, and every JavaScript callback runs on that one event loop thread. The impression of simultaneity comes from the fact that I/O operations, such as file reads, network calls and database queries, do not hold the event loop while they wait. The loop moves on to other events and comes back when the I/O resolves. That is not parallelism; it is cooperative scheduling. Underneath, libuv keeps a small worker pool for I/O that the operating system offers no non-blocking version of, such as file system calls, and for some native CPU-heavy work such as crypto.pbkdf2() and zlib. Only long-running work on the event loop thread blocks the loop directly.
Correction (2 November 2026, 18:45 Taipei): added libuv’s worker pool for file I/O and some native CPU work, and limited the single-thread claim to JavaScript callbacks on the event loop (suggested by Codex (gpt-6-astra), checked by Claude).
The mental model I find most useful: picture a single very quick waiter working a busy restaurant. He takes orders at every table, steps away while the kitchen cooks, and comes back when something is ready. That works brilliantly right up until one table orders a dish that takes 30 minutes to prepare, and the waiter decides to stand there and watch it cook. Every other table waits. I/O-heavy workloads (API gateways, WebSocket fan-out, proxies) rarely make the waiter stand still, so the loop stays fast. CPU-heavy work done in JavaScript (image processing, synchronous cryptography, real-time game physics) is that 30-minute dish. Nothing else gets served while the event loop thread is occupied.
worker_threads exists precisely for this case, and it does work — but it is not free. You pay in serialisation overhead, message-passing latency, and the extra design surface that cross-thread communication demands. This is part of why I reach for a different language or runtime on certain backends. This is simply because the tool does not fit the job, not due to any flaw in JavaScript. A game server doing real-time physics simulation patched together with worker_threads is a harder path than simply starting with something designed for that load. An I/O-dominated API service that needs fast iteration? Node is a sensible default there.
Tool selection is ultimately about acknowledging that every tool has a boundary, then matching it to the workload rather than forcing the workload to conform to the tool.
Notes
- Node.js handles high concurrency through non-blocking I/O and the event loop; every JavaScript callback runs on the one event loop thread, while libuv’s worker pool takes file I/O and some native CPU work.
- I/O-heavy workloads are the sweet spot; CPU-heavy work on the event loop thread blocks the entire loop and degrades every concurrent connection.
worker_threadscan offload CPU work but introduces real costs in serialisation and inter-thread coordination.- “It runs” is not the same as “it fits”; understand your workload’s character before choosing a runtime.
- The event loop in one image: one waiter, many tables, one dish that takes forever, everyone waits.
Ideas and technical judgement by Sheng; drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.