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.
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.
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 application instruments its own live trace: rows and timestamps are recorded by real emit(...) calls, while the scenario supplies source and lane labels. This is not a V8/libuv profiler or a direct view of their internal queues. await and Promise keep the HTTP stream open until the scenario completes.
How a learning mistake becomes an incident
A realistic service: the original code, observable failure, corrected implementation, and why the correction works.
CPU-heavy risk scoring erases Node.js I/O throughput
A gateway efficiently handles many network requests, then adds a synchronous fraud model that evaluates thousands of rules for every checkout.
Node is efficient while the main isolate mostly coordinates I/O. The new CPU phase runs to completion and serializes unrelated requests behind it.
@Controller('checkout')
export class CheckoutController {
constructor(private readonly customers: CustomerService) {}
@Post()
async checkout(@Body() input: CheckoutDto) {
const customer = await this.customers.get(input.userId);
const score = evaluateRiskRules(customer, input);
return { approved: score < 70 };
}
}The asynchronous fetch releases the stack, but evaluateRiskRules is ordinary synchronous JavaScript. More open sockets do not create CPU parallelism.
@Injectable()
export class RiskService {
constructor(
private readonly customers: CustomerService,
@Inject(RISK_POOL)
private readonly workerPool: RiskWorkerPool,
) {}
async evaluate(input: CheckoutDto) {
const customer = await this.customers.get(input.userId);
return this.workerPool.run({ customer, checkout: input });
}
}
@Controller('checkout')
export class CheckoutController {
constructor(private readonly risk: RiskService) {}
@Post()
async checkout(@Body() input: CheckoutDto) {
const score = await this.risk.evaluate(input);
return { approved: score < 70 };
}
}A bounded Worker pool uses additional CPU cores without blocking the HTTP isolate. A maximum queue makes overload visible and enables fast rejection instead of unlimited memory growth.
What the unfamiliar calls from both code samples actually do.
ledger.history(userId)- A representative I/O call for transaction history. await releases the stack while a database or service responds.
calculateRisk(history)- A representative synchronous CPU-heavy function. An async controller does not make it parallel automatically.
workerPool.run(data)- Sends a serializable task to an available Worker Thread and returns a Promise with the result.
maxQueue- The maximum number of waiting worker tasks. On overflow, reject quickly instead of growing memory without a bound.
@Inject(RISK_POOL)- Nest injects the pool by DI token, letting a module control its size, lifecycle, and test replacement.
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?