Java vs. Node.js Performance: A Comparative Analysis
Introduction
Node.js or Java — Which Should You Choose for a new microservice ?
When deciding between Node.js and Java for a new microservice, several critical questions arise:
- Will Java scale effectively with growing demands?
- Can NodeJS handle CPU-bound tasks efficiently?
- How do these two technologies compare in terms of performance?
To answer these questions, it’s essential to set up test scenarios that evaluate both technologies under typical workloads. Here’s how I approached it:
Test Scenarios:
- CPU-Bound Test: Evaluates how well each technology handles tasks that require intensive CPU processing.
- I/O-Bound Test: Assesses the performance of each technology when dealing with operations like file handling, database queries, or network requests.
By running these tests, we can compare the strengths and weaknesses of Node.js and Java in real-world situations, helping to make an informed decision for the microservice.
CPU Bound Test Case
NodeJS
const secondsToWait = 1;
@Post('/cpu-bound')
async cpubound(): Promise<any> {
const waitTime = secondsToWait * 1000000;
const startTime = process.hrtime.bigint();
while (process.hrtime.bigint() - startTime < BigInt(waitTime)) {
// waiting utilizing cpu, incresing cpu usage.
}
return {status: "ok"};
}Java
int secondsToWait = 1;
@PostMapping("/cpu-bound")
public ResponseEntity<?> cpuBound() {
long waitTime = secondsToWait * 1000000L;
long startTime = System.nanoTime();
while ((System.nanoTime() - startTime) < waitTime) {
// waiting utilizing cpu, incresing cpu usage.
}
return ResponseEntity.ok("{\"status\": \"ok\" }");
}IO Bound Test
NodeJS
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const secondsToWait = 1;
@Post('/io-bound')
async iobound(): Promise<any> {
// waiting without utilizing cpu
// io wait, operation is performing is some other system
await sleep(secondsToWait * 1000);
return {status: "ok"};
}Java
int secondsToWait = 1;
@PostMapping("/io-bound")
public ResponseEntity<?> ioBound() throws Exception {
// waiting without utilizing cpu
// io wait, operation is performing is some other system
Thread.sleep(secondsToWait * 1000);
return ResponseEntity.ok("{\"status\": \"ok\" }");
}Once we have pinned down our test scenarios, we can start testing, to test these APIs I am going to use K6 load testing tool. Check it out https://k6.io/
K6 — Testing Script
import http from 'k6/http';
import {check} from 'k6';
export const options = {
vus: 1,
duration: '1s',
thresholds: {
// http errors can be max 5%
http_req_failed: ['rate<0.06'],
}
};
const BASE_URL = `http://localhost:${PORT}`;
export default function () {
const headers = {};
const body = {};
let url = `${BASE_URL}/api/v1/cpu-bound`
if (__ENV.TEST_TYPE === "IO") {
// change url when testing io
url = `${BASE_URL}/api/v1/io-bound`
}
const response = http.post(url, body, {headers});
check(response, {
// check the response should be 200
'is status 200': (r) => r.status === 200 || r.status === 201,
// check the response body should contain status as ok
'token is present': (r) => {
const res = r.json()
return res.status && res.status.length > 0 && res.status === 'ok'
},
});
}Start Testing : 4 GB Memory & 8 vCPUs
CPU Bound — Java
Running k6, puts load on the server, I am putting 150 virtual/parallel users, and each user would be using the server for at least 30 seconds.
k6 run .\script-java.js --vus=150 --duration=30scpu: 100 %
memory: 2 GB
http: 18 req/s
// In Java Spring boot one threads is used for each request that comes
// and context switching is done to achive parallismCPU Bound — NodeJS
k6 run .\script-node.js --vus=150 --duration=30scpu: 100 %
memory: ~ 1 GB
http: 1 req/s
// node js is single threaded with a event loop
// and the test case wait for 1 sec busy wait
// thus only 1 req is process at a time per secondCPU Bound — NodeJS ( Clustering )
In NodeJS, we can use the cluster module to create multiple NodeJS processes, each listening on the same TCP port. This allows the application to handle multiple TCP connections concurrently. The operating system acts as a load balancer, distributing incoming requests across the different Node worker processes, typically using a round-robin approach. This setup takes advantage of multi-core processors, improving the scalability and performance of the application.
async function main() {
const numCPUs = os.cpus().length;
console.log(`${numCPUs} cpu cores found`);
if (cluster.isPrimary) {
console.log(`Primary process(${process.pid}) started`);
// start 1 process per cpu core using fork
for (let i = 0; i < numCPUs; i++) {
// creates a new node process
// which starts executing the same script
// but cluster.isPrimary is false for worker
const worker = cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`worker process(${worker.process.pid}) exited with code ${code} and ${signal}`);
});
} else {
// Workers can share any TCP connection
// In this case it is an HTTP server
startServer(8080);
console.log(`Worker process(${process.pid}) started`);
}
}Testing again
k6 run .\script-node.js --vus=150 --duration=30scpu: 100 %
memory: ~ 2 GB
http: 8 req/s
// 8 process for 8 vCPUs serving 1 req/s, thus 8 req/s utilizing all cores
// context switching between process is expensive,
// In java which is with threads is better at serving cpu bound requestsIO Bound — Java
In I/O-bound tests, the performance of the server is primarily limited by the speed of disk access, network operations, or database queries, rather than CPU processing power. When the number of parallel users (VUs) is too low, the server might handle the load efficiently, leading to similar results. Thus increasing the parallel user helps.
k6 run .\script-java.js --vus=1000 --duration=10s -e TEST_TYPE=iocpu: ~ 60 - 70 %
memory: ~ 4 GB
https: 190 req/sIO Bound — NodeJS
k6 run .\script-node.js --vus=1000 --duration=10s -e TEST_TYPE=iocpu: ~ 45 %
memory: ~ 2 GB
http: 804
// clearly node js is much better at handling io bound requestIO Bound — NodeJS ( Clustering )
k6 run .\script-java.js --vus=1000 --duration=10s -e TEST_TYPE=iocpu: ~ 70 %
memory: ~ 3 GB
http: 951
// clustering improves the node js performance even betterConclusion
The performance comparison between Java and Node.js reveals distinct strengths and weaknesses in handling different types of workloads.