Node.js vs Java, Go, and Python
Learn why Node is efficient for I/O, where the Event Loop advantage ends, and which models Java, Go, and Python provide.
Timeline
Understanding: Node.js vs Java, Go, and Python
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
Node is less like one slow worker and more like a dispatcher that does not stand beside every waiting order. While a database, network, or disk is working, the main JavaScript thread can serve other ready events. This saves threads during I/O waiting; it does not make heavy JavaScript parallel.
One Node process contains a V8 isolate with a main JavaScript Event Loop, V8 service threads, and libuv facilities. Sockets usually rely on OS readiness, some fs/crypto/DNS work uses a bounded libuv pool, and CPU-bound JavaScript can move to Worker Threads or processes. “Node is single-threaded” describes JavaScript execution in one isolate, not the entire process.
Terms used in this experiment
Understand the words first, then the execution order.
Concurrency
Multiple tasks are in progress with overlapping lifetimes; their instructions need not execute at the same instant.
Parallelism
Instructions actually execute simultaneously on multiple cores or hardware threads.
I/O-bound
Work dominated by waiting for a network, disk, database, or another external resource.
CPU-bound
Work limited by computation such as serialization, compression, image processing, cryptography, or large loops.
V8 isolate
An isolated V8 environment with its own heap and JavaScript execution state. A Worker Thread creates another isolate.
Throughput
Successfully completed operations per unit of time; it is not the latency of one operation.
What happens step by step
Each step maps to an observable runtime state.
- 01Classify the work
Separate short JavaScript, network waits, native operations, and heavy CPU code.
- 02Node registers I/O
The main thread delegates waiting to the OS or libuv and returns to ready callbacks.
- 03Readiness returns a continuation
A callback or Promise continuation waits for a free JavaScript stack.
- 04CPU changes the picture
Long synchronous JavaScript occupies the isolate and delays all of its connections.
- 05Choose a parallel boundary
Use Worker Threads for CPU/shared memory, processes for isolation, or job queues for distributed work.
- 06Verify the model
Compare throughput, p95/p99 latency, Event Loop delay, CPU, memory, and overload behavior.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
Java is not only heavyweight thread-per-request
Java has platform threads, NIO/reactive designs, and virtual threads. A virtual thread can unmount during blocking I/O, scaling synchronous code without making CPU work faster.
Go is not one OS thread per goroutine
The Go scheduler multiplexes lightweight goroutines over OS threads and can run work in parallel across available cores.
Python depends on implementation and build
asyncio also uses an Event Loop for I/O. Default CPython serializes much Python bytecode with the GIL, while multiprocessing, native extensions, and optional free-threaded builds change the boundary.
Node is not efficient at everything
Many waiting sockets are a strong fit. Large synchronous serialization or computation per request can turn one isolate into the bottleneck.
Architecture can outweigh the language
Connection pools, backpressure, algorithms, batching, caches, process counts, and limits often dominate the result.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
const waits = Array.from({ length: 24 }, (_, index) =>
delay(25 + (index % 4) * 10)
);
// One Event Loop coordinates all pending waits:
await Promise.all(waits);
// This CPU work occupies the main JavaScript isolate:
heavyCpuWork(180);The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
import { performance } from 'node:perf_hooks';
const sleep = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
function blockMainThread(durationMs) {
const startedAt = performance.now();
let iterations = 0;
// Намеренная блокировка для учебного сценария. В production так делать нельзя.
while (performance.now() - startedAt < durationMs) {
iterations += Math.sqrt((iterations % 10_000) + 1);
}
return Math.round(iterations);
}
async function runtimeModelsComparison(emit) {
const taskCount = 24;
const startedAt = performance.now();
emit(
'main-js',
'schedule',
`Регистрируем ${taskCount} I/O-подобных ожиданий без ${taskCount} JavaScript-потоков`,
);
const waits = Array.from({ length: taskCount }, (_, index) =>
sleep(25 + (index % 4) * 10).then(() => index),
);
emit(
'main-js',
'sync',
`Все ожидания зарегистрированы за ${(
performance.now() - startedAt
).toFixed(1)} мс; стек снова свободен`,
);
const results = await Promise.all(waits);
emit(
'event-loop',
'result',
`${results.length} continuations выполнены тем же Event Loop после готовности таймеров`,
);
const timerStartedAt = performance.now();
const delayedTimer = new Promise((resolve) => {
setTimeout(() => {
emit(
'event-loop',
'warning',
`Таймер после CPU-блокировки вошёл в стек через ${Math.round(
performance.now() - timerStartedAt,
)} мс`,
);
resolve();
}, 20);
});
emit(
'main-js',
'warning',
'Теперь 180 мс CPU-bound JavaScript блокируют один главный isolate',
);
blockMainThread(180);
await delayedTimer;
emit(
'architecture',
'result',
'Вывод: дешёвое ожидание I/O даёт throughput, но CPU-параллелизм требует Worker, процессов или другого runtime-подхода',
);
}The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.
Common misconceptions
The myth is on the left; the accurate model is on the right.
Node.js is entirely single-threaded.
One thread normally executes JavaScript for one isolate, while the process also uses service threads, the libuv pool, and optional Worker Threads.
Async code automatically uses every CPU core.
It avoids wasting a thread while waiting. CPU parallelism needs multiple isolates/processes or a parallel native API.
Java always creates an expensive OS thread per request.
That is one model among platform threads, NIO/reactive systems, and JVM-scheduled virtual threads.
Python threads can never run in parallel under any conditions.
The answer depends on CPython/GIL mode, free-threaded builds, native code, and the chosen multiprocessing or asyncio architecture.
High throughput guarantees low latency.
A service can complete many requests per second and still have poor p95/p99 tail latency.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why are ten thousand open sockets fundamentally different from ten thousand active CPU computations?
- When would you choose a Worker Thread, a process, or a BullMQ Worker?
- Which measurements prove an advantage instead of repeating a marketing claim?