Node.js Messaging Costs
A recent Hacker News thread about concurrency across multiple cores led to some interesting claims about Node.js's parallel performance—specifically, whether message passing in Node can hold its own against a proper parallel VM. The short answer, based on some quick benchmarks, is no.
For web servers, Node.js does offer decent speedup over multiple cores via the cluster module. But that speedup comes with some significant overhead. Like any POSIX program, Node relies on multiprocess IPC for parallelism, which carries higher costs than shared-memory approaches. It can't use atomic CPU operations on shared data structures, and it pays the context-switch penalty that comes with crossing process boundaries.
Threads and processes both require context switches, but on POSIX systems, switching threads is notably cheaper. Process switches require changing the VM address space, which means reloading cache from DRAM. Every message shared between processes also requires crossing the kernel boundary, adding CPU cost and cache churn. This matters particularly for JSON-heavy web apps, where parsing and serialization already eat a significant portion of CPU time.
Benchmarking the Overhead
To quantify the gap, consider two small programs that pass integer messages between two threads—one in Node.js, one in Clojure using a pair of LinkedTransferQueues. Integers were deliberately chosen to minimize serialization costs for Node.
$ time node cluster.js
Finished with 10000000
real 3m30.652s
user 3m17.180s
sys 1m16.113s
The Node version shows high system time, which reflects IPC overhead, and only uses about 75% of each core. The reason becomes clear when you look at the context-switch count:
$ pidstat -w | grep node
12:13:24 PM PID cswch/s nvcswch/s Command
11:47:47 AM 25258 48.22 2.11 node
11:47:47 AM 25260 48.34 1.99 node
$ strace -cf node cluster.js
Finished with 1000000
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
97.03 5.219237 31 168670 nanosleep
1.63 0.087698 0 347937 61288 futex
1.01 0.054567 0 1000007 1 epoll_wait
0.20 0.010581 0 1000006 write
0.11 0.005863 0 1000005 recvmsg
Each write() requires a syscall, and every read needs epoll_wait() and recvmsg()—about 3.5 syscalls per message. Roughly 34% of messages involved futex, which suggests the implementation avoids busy-polling, but the overhead remains substantial.
The Clojure version, by contrast, uses 97% of each core and is more than three times faster, even including about a second of JVM startup time:
$ time java -jar target/messagepassing-0.1.0-SNAPSHOT-standalone.jar queue
10000000
"Elapsed time: 53116.427613 msecs"
real 0m54.213s
user 1m16.401s
sys 0m6.028s
The JVM version triggers only 11 context switches per second:
$ pidstat -tw -p 26537
Linux 3.2.0-3-amd64 (azimuth) 07/29/2012 _x86_64_ (2 CPU)
11:52:03 AM TGID TID cswch/s nvcswch/s Command
11:52:03 AM 26537 - 0.00 0.00 java
11:52:03 AM - 26540 0.01 0.00 |__java
11:52:03 AM - 26541 0.01 0.00 |__java
11:52:03 AM - 26544 0.01 0.00 |__java
11:52:03 AM - 26549 0.01 0.00 |__java
11:52:03 AM - 26551 0.01 0.00 |__java
11:52:03 AM - 26552 2.16 4.26 |__java
11:52:03 AM - 26553 2.10 4.33 |__java
Using compare-and-set operations instead of queues nearly eliminates context switching entirely:
$ time java -jar target/messagepassing-0.1.0-SNAPSHOT-standalone.jar atom
10000000
"Elapsed time: 999.805116 msecs"
real 0m2.092s
user 0m2.700s
sys 0m0.176s
$ pidstat -tw -p 26717
Linux 3.2.0-3-amd64 (azimuth) 07/29/2012 _x86_64_ (2 CPU)
11:54:49 AM TGID TID cswch/s nvcswch/s Command
11:54:49 AM 26717 - 0.00 0.00 java
11:54:49 AM - 26720 0.00 0.01 |__java
11:54:49 AM - 26728 0.01 0.00 |__java
11:54:49 AM - 26731 0.00 0.02 |__java
11:54:49 AM - 26732 0.00 0.01 |__java
Distinguishing the JVM startup syscalls from the benchmark itself, the marginal cost of each message is roughly one futex per 24,000 operations—likely from idle threads—meaning the actual message passing is effectively free of kernel overhead.
What Node Gets Right
This isn't a dismissal of Node.js, but rather a clarification of what it is good for. Node's multiprocess IPC is not a substitute for a shared-memory parallel VM. It handles problems that require infrequent communication across cores, but shared state is effectively off the table, and frequent message passing is slow.
Node excels at stateless web heads where the heavy lifting—shared state, complex coordination—is deferred to a separate component like a database. For problems that are largely independent and don't require tight coupling between workers, Node's model works fine. But as a high-performance parallel environment, it's not in the same league as languages that support shared-memory concurrency with atomic primitives.
Different languages provide different concurrency strategies, and some offer a far richer toolset than others. The key is matching the language to the problem's communication and state requirements.



