Blocking vs Worker
Compare heartbeat gaps and a real HTTP load test with CPU work on main versus a bounded Worker pool.
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 Node HTTP callbacks, the Nest request lifecycle, 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.
p95 / p99
Tail-latency boundaries: 95% or 99% of requests completed at or below this value. An average can hide a small group of very slow requests.
Queue depth
The number of CPU jobs waiting for a free Worker. A growing queue means admission exceeds pool capacity.
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.
- 06Real HTTP load test
A separate generator sends the same /fast and /cpu mix first to a main-thread handler and then to a two-Worker pool.
- 07Compare the tails
RPS describes throughput; fast p95, overall p99, Event Loop delay, and max queue explain each mode’s cost to different requests.
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.
One short run is not a capacity plan
Numbers depend on CPU, OS, Node version, and background load. This experiment demonstrates causality; production sizing needs warm-up, repeated runs, and a realistic traffic profile.
The public control plane is isolated
On the open site, the entire CPU scenario runs in a disposable child process with a timeout, memory cap, and bounded queue. Private/dev runs it directly so the lab owner can observe the main process impact.
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',
);
await runLoadComparison(emit);
}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.
PDF generation runs inside a Nest controller handler
A NestJS B2B service generates a multipage invoice with charts. The first implementation computes layout and compresses images directly inside the controller handler.
CPU-bound rendering occupies the main isolate for seconds. One large document increases latency for every client of that Node process and can fail its readiness probe.
@Controller('invoices')
export class InvoicesController {
constructor(private readonly invoices: InvoicesService) {}
@Get(':id/pdf')
@Header('Content-Type', 'application/pdf')
async download(@Param('id') id: string) {
const invoice = await this.invoices.get(id);
// CPU-bound layout and image compression
const pdf = renderInvoicePdf(invoice);
return new StreamableFile(pdf);
}
}Marking a handler async does not move synchronous renderInvoicePdf work to another thread. Until it returns, this isolate cannot serve other callbacks.
@Controller('invoices')
export class InvoicesController {
constructor(
@InjectQueue('invoice-export')
private readonly exportQueue: Queue,
) {}
@Post(':id/export')
@HttpCode(HttpStatus.ACCEPTED)
async startExport(@Param('id') id: string) {
const job = await this.exportQueue.add(
'invoice-pdf',
{ invoiceId: id },
{ jobId: 'invoice-' + id },
);
return {
jobId: job.id,
statusUrl: '/exports/' + job.id,
};
}
}
// A separate Nest worker application/container:
@Processor('invoice-export')
export class InvoiceExportProcessor extends WorkerHost {
process(job: Job<{ invoiceId: string }>) {
return renderAndStorePdf(job.data.invoiceId);
}
}The HTTP process validates and enqueues a bounded job. A separate worker process performs the CPU work and can be scaled or restarted independently.
What the unfamiliar calls from both code samples actually do.
@Get / @Param- Nest registers a GET route, while @Param extracts the dynamic :id segment from the URL.
new StreamableFile(buffer)- A Nest wrapper for sending a Buffer or Stream as a file. It does not move file generation to another thread.
renderInvoicePdf(invoice)- A representative synchronous CPU-heavy function that builds and returns a PDF Buffer while blocking its isolate.
queue.add(name, data, options)- BullMQ stores a job in Redis. data is serializable input, while options configure identity, retries, and other rules.
@InjectQueue(name)- Nest BullMQ injects the Queue registered under this name through the DI container.
@Processor / WorkerHost- Nest BullMQ registers a queue consumer; process receives a Job and performs background work.
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?
- Why can worker mode have a high overall p95 while fast p95 and Event Loop delay are much lower?