CPU-bound work
04

Blocking vs Worker

Compare heartbeat gaps and a real HTTP load test with CPU work on main versus a bounded Worker pool.

PROCESS IDcurrent server
UPTIMEsince startup
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Timeline

READY
0 ms
Events will appear hereRun the selected scenario
#TIMESOURCEEVENT
Waiting for an experiment…
CHAPTER 04
DEEP DIVE · FROM BASICS TO CODE

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.

FIRST, IN PLAIN LANGUAGE

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.

TECHNICAL FOUNDATION

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.

Why it mattersBlocking the main thread delays every route, timer, and client in the process. Workers preserve responsiveness but require a bounded pool and careful data transfer.
WHERE THE WORK RUNS
01YOUR JS CODEfunctions · callbacks
02NODE APIsfs · crypto · timers
03V8 + LIBUVheap · loop · pool
04OPERATING SYSTEMI/O · threads · memory
01 · GLOSSARY

Terms used in this experiment

Understand the words first, then the execution order.

01

Main Thread

The thread running Node HTTP callbacks, the Nest request lifecycle, and the primary application JavaScript.

02

CPU-bound

Work whose speed is limited by CPU calculation rather than waiting for an external resource.

03

Worker Thread

A separate Node JavaScript environment with its own V8 isolate.

04

Message passing

Data exchange through postMessage; values are cloned, transferred, or shared.

05

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.

06

Queue depth

The number of CPU jobs waiting for a free Worker. A growing queue means admission exceeds pool capacity.

02 · MECHANICS

What happens step by step

Each step maps to an observable runtime state.

  1. 01
    Main heartbeat

    A timer creates control events roughly every 70 ms.

  2. 02
    Blocking

    A synchronous CPU loop does not return control to the Event Loop.

  3. 03
    Accumulated lag

    The heartbeat cannot interrupt the calculation and runs only afterward.

  4. 04
    Worker creation

    The same calculation idea runs in a separate V8 isolate.

  5. 05
    Result message

    Main keeps producing heartbeats and later receives an async message callback.

  6. 06
    Real 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.

  7. 07
    Compare the tails

    RPS describes throughput; fast p95, overall p99, Event Loop delay, and max queue explain each mode’s cost to different requests.

03 · CONTEXT

Where the result needs context

These details explain why similar code can sometimes produce a different trace.

01

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.

02

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.

03

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.

04

Large messages have a cost

Normal values use structured cloning. Large ArrayBuffers can transfer ownership through a transfer list; SharedArrayBuffer requires application-level synchronization.

05

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.

06

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.

01
Theory

First understand which parts of Node participate in execution.

02
Simplified code

Then remove instrumentation and focus on the central mechanism.

03
Runtime code

Finally match the model to the code that produces the live trace.

04 · Simplified code

A minimal model without instrumentation

src/demos.js · educational snippetJavaScript
// 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);
});
05 · Runtime code

The complete code executed by the scenario

This is not an alternative example: these are the functions and files used by the Run button.

ACTUAL SOURCE

The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.

src/demos.js
scenario93 lines
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.

06 · PRODUCTION CASES

How a learning mistake becomes an incident

A realistic service: the original code, observable failure, corrected implementation, and why the correction works.

CASE 01

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.

INCIDENT CONTEXT

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.

BEFOREPROBLEMATIC IMPLEMENTATION
@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.

AFTERCORRECTED IMPLEMENTATION
@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.

FUNCTIONS AND CONSTRUCTS

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.
WHY THE CORRECTION WORKS

A Worker Thread suits short CPU work that must return quickly. A durable queue suits long work that must survive an HTTP-process restart. The SLA and durability requirement determine the choice.

WHAT PRODUCTION SHOWED
  • Heartbeat gaps coincide with exports of large documents.
  • p99 rises on every route although their dependencies are fast.
  • Restarting the HTTP process loses an unfinished PDF.
07 · DO NOT CONFUSE

Common misconceptions

The myth is on the left; the accurate model is on the right.

MYTH

Wrapping a calculation in async moves it to another thread.

ACTUALLY

async changes the return value to a Promise; synchronous body code still runs in the current thread.

MYTH

A Promise makes work parallel.

ACTUALLY

A Promise models a future value; the API decides where the work executes.

MYTH

Create a new Worker for every HTTP request.

ACTUALLY

Isolate startup is expensive; production systems usually use a bounded worker pool.

MYTH

A Worker is guaranteed to speed up every task.

ACTUALLY

A short task may become slower because of startup and messaging. The primary win in this lab is main-thread responsiveness.

08 · SELF-CHECK

Explain it in your own words

If you can explain the answer without quoting documentation, your mental model is starting to take shape.

  1. Why can setInterval not interrupt a while loop?
  2. Which values are expensive to send through structured cloning?
  3. Why can worker mode have a high overall p95 while fast p95 and Event Loop delay are much lower?