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.