Blocking vs Worker
Compare a heartbeat gap caused by main-thread blocking with the same calculation in a Worker Thread.
Timeline
Understanding: Blocking vs Worker
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
The main thread is like the only emergency operator. If they spend minutes calculating a spreadsheet, they stop answering calls. A Worker is another operator that can perform the calculation while the first line stays available.
The Event Loop scales waiting for I/O, not CPU-bound JavaScript. A Worker Thread creates a separate V8 isolate, call stack, and Event Loop inside the same process. Data is exchanged through structured cloning, transfer lists, or SharedArrayBuffer.
Terms used in this experiment
Understand the words first, then the execution order.
Main Thread
The thread running Express, HTTP callbacks, and the primary application JavaScript.
CPU-bound
Work whose speed is limited by CPU calculation rather than waiting for an external resource.
Worker Thread
A separate Node JavaScript environment with its own V8 isolate.
Message passing
Data exchange through postMessage; values are cloned, transferred, or shared.
What happens step by step
Each step maps to an observable runtime state.
- 01Main heartbeat
A timer creates control events roughly every 70 ms.
- 02Blocking
A synchronous CPU loop does not return control to the Event Loop.
- 03Accumulated lag
The heartbeat cannot interrupt the calculation and runs only afterward.
- 04Worker creation
The same calculation idea runs in a separate V8 isolate.
- 05Result message
Main keeps producing heartbeats and later receives an async message callback.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
Responsive does not mean faster
The experiment proves that the main thread stays available. It does not guarantee lower compute time: Worker creation, isolate startup, and data transfer all have a cost.
Threads still share CPU
The Worker is separate from the server Event Loop but competes for cores, memory, and the same process/container cgroup. Heartbeats may still jitter on a saturated CPU.
A Worker is not a child process
A Worker has its own V8 isolate and JavaScript heap but remains inside the same process and can use shared memory. Isolation is weaker than child_process, while communication is usually cheaper.
Large messages have a cost
Normal values use structured cloning. Large ArrayBuffers can transfer ownership through a transfer list; SharedArrayBuffer requires application-level synchronization.
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
// Bad on the main thread:
heavyCpuWork(360);
// Move CPU-bound work out:
const worker = new Worker('./cpu-worker.js');
worker.on('message', result => {
console.log(result);
});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';
import { Worker as ThreadWorker } from 'node:worker_threads';
const workerPath = new URL('./cpu-worker.js', import.meta.url);
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);
}
function startHeartbeat(emit, lane, label, durationMs) {
const intervalMs = 70;
const startedAt = performance.now();
let expectedAt = startedAt + intervalMs;
let number = 0;
const interval = setInterval(() => {
number += 1;
const now = performance.now();
const lag = Math.max(0, Math.round(now - expectedAt));
emit(
lane,
lag > 80 ? 'warning' : 'heartbeat',
`${label} #${number}; задержка ${lag} мс`,
);
expectedAt = now + intervalMs;
}, intervalMs);
return new Promise((resolve) => {
setTimeout(() => {
clearInterval(interval);
resolve(Math.round(performance.now() - startedAt));
}, durationMs);
});
}
async function blockingComparison(emit) {
emit('result', 'section', 'Часть A — CPU-работа в главном потоке');
const mainHeartbeat = startHeartbeat(emit, 'main-thread', 'Пульс main', 650);
await sleep(150);
emit('call-stack', 'warning', 'Блокируем главный поток примерно на 360 мс');
const iterations = blockMainThread(360);
emit(
'call-stack',
'sync',
`Главный поток снова свободен (${iterations.toLocaleString('ru-RU')} итераций)`,
);
await mainHeartbeat;
emit('result', 'section', 'Часть B — та же работа в Worker Thread');
const workerHeartbeat = startHeartbeat(emit, 'main-thread', 'Пульс main', 650);
await sleep(150);
emit('worker-thread', 'schedule', 'CPU-задача отправлена отдельному Worker');
const workerResult = await new Promise((resolve, reject) => {
const worker = new ThreadWorker(workerPath, {
workerData: { durationMs: 360 },
});
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) reject(new Error(`Worker завершился с кодом ${code}`));
});
});
emit(
'worker-thread',
'callback',
`Worker закончил за ${workerResult.durationMs} мс; main всё это время отправлял пульс`,
);
await workerHeartbeat;
emit(
'result',
'result',
'Сравните разрыв heartbeat в части A с равномерными событиями в части B',
);
}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.
Wrapping a calculation in async moves it to another thread.
async changes the return value to a Promise; synchronous body code still runs in the current thread.
A Promise makes work parallel.
A Promise models a future value; the API decides where the work executes.
Create a new Worker for every HTTP request.
Isolate startup is expensive; production systems usually use a bounded worker pool.
A Worker is guaranteed to speed up every task.
A short task may become slower because of startup and messaging. The primary win in this lab is main-thread responsiveness.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why can setInterval not interrupt a while loop?
- Which values are expensive to send through structured cloning?