One stack, many ready tasks
03

Callback queue

Five zero-delay timers show how one expensive callback delays the entire queue.

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 03
DEEP DIVE · FROM BASICS TO CODE

Understanding: Callback queue

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

Imagine one service window. Five people may already be ready, but the clerk handles only one person at a time. If the first customer takes a long time, everyone else waits.

TECHNICAL FOUNDATION

A queue stores ready work; it does not execute it. The JavaScript call stack remains the executor. After every callback, priority nextTick and microtask queues may run before the next timer callback. This lab measures from registration, so its number is broader than pure queue waiting time.

Why it mattersQueue delay affects the latency of the entire server. One slow callback can delay thousands of otherwise independent connections.
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

Queue

A waiting structure. Being queued does not mean the callback is currently executing.

02

Lag

Delay relative to an expected moment. Here the value is total time since registration, including the minimum timer threshold and stack waiting.

03

Run-to-completion

The current callback runs until it returns; the Event Loop does not preempt it.

04

Starvation

A priority queue keeps refilling and prevents other phases from receiving execution time.

02 · MECHANICS

What happens step by step

Each step maps to an observable runtime state.

  1. 01
    Five registrations

    All setTimeout(0) calls are created in one short synchronous loop.

  2. 02
    Timers become ready

    Their minimum deadline passes, but the stack may still be busy.

  3. 03
    First callback blocks

    A CPU loop owns the only main JavaScript stack for about 260 ms.

  4. 04
    Priority queues run

    nextTick and Promise from the first callback run before timer #2.

  5. 05
    The queue drains

    The remaining callbacks execute quickly, one after another.

03 · CONTEXT

Where the result needs context

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

01

setTimeout(0) is not ready instantly

Zero defines a minimum threshold, not an immediate call. The timer must become eligible, the Event Loop must reach timers, and the stack must be free.

02

The trace is not pure queue wait

The experiment counts milliseconds from common registration. That is useful end-to-end latency, but exact queue wait would require a separate timestamp for each timer’s internal readiness.

03

FIFO here is controlled, not global

Five identical timers are created by one loop and normally run in registration order. Do not extend that result to callbacks from different phases, I/O sources, or threads.

04

Microtasks can cause starvation

After callback #1, Node runs its nextTick and Promise before timer #2. If nextTick recursively keeps adding itself, other Event Loop phases can be starved.

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
for (let i = 1; i <= 5; i++) {
  setTimeout(() => {
    if (i === 1) heavyCpuWork(260);
    console.log('timer', i);
  }, 0);
}
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
scenario74 lines
import { performance } from 'node:perf_hooks';

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 callbackQueue(emit) {
  const scheduledAt = performance.now();
  emit('call-stack', 'sync', 'Одновременно ставим пять setTimeout(0)');

  await new Promise((resolve) => {
    let completed = 0;

    for (let index = 1; index <= 5; index += 1) {
      emit('timers', 'schedule', `Таймер #${index} зарегистрирован`);

      setTimeout(() => {
        const lag = Math.round(performance.now() - scheduledAt);
        emit(
          'timers-queue',
          'callback',
          `Callback #${index} взят из очереди (через ${lag} мс)`,
        );

        if (index === 1) {
          emit(
            'call-stack',
            'warning',
            'Callback #1 занимает главный поток на 260 мс',
          );
          blockMainThread(260);
          emit('call-stack', 'sync', 'Callback #1 освободил стек');

          process.nextTick(() => {
            emit(
              'nextTick',
              'callback',
              'nextTick из callback #1 вклинился перед следующим таймером',
            );
          });

          Promise.resolve().then(() => {
            emit(
              'microtasks',
              'callback',
              'Promise из callback #1 тоже выполнен перед следующим таймером',
            );
          });
        }

        completed += 1;
        if (completed === 5) {
          // setImmediate даёт nextTick/Promise последнего callback'а выполниться
          // до завершения учебного сценария.
          setImmediate(resolve);
        }
      }, 0);
    }
  });

  emit(
    'result',
    'result',
    'Очередь не исполняет callbacks параллельно: каждый ждёт свободный стек',
  );
}

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

Synchronous password hashing delays every request in the process

A registration endpoint uses bcrypt.hashSync. One call looks acceptable in development, but concurrent registrations force callbacks from unrelated routes to wait for the stack.

INCIDENT CONTEXT

Ready timers, HTTP callbacks, and even the health check cannot execute while hashSync owns the main thread. Autoscaling reacts late because CPU and Event Loop lag have already raised tail latency.

BEFOREPROBLEMATIC IMPLEMENTATION
@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Post()
  create(@Body() input: CreateUserDto) {
    const passwordHash = bcrypt.hashSync(
      input.password,
      12,
    );

    return this.users.create({
      email: input.email,
      passwordHash,
    });
  }
}

The synchronous CPU/native operation runs inside the current callback to completion. A queue of ready HTTP callbacks is not a parallel executor.

AFTERCORRECTED IMPLEMENTATION
@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Post()
  async create(@Body() input: CreateUserDto) {
    const passwordHash = await bcrypt.hash(
      input.password,
      12,
    );

    return this.users.create({
      email: input.email,
      passwordHash,
    });
  }
}

Asynchronous bcrypt delegates the work and releases the main stack. The route still needs rate limiting because the native pool and CPU remain finite resources.

FUNCTIONS AND CONSTRUCTS

What the unfamiliar calls from both code samples actually do.

@Controller / @Post
Nest decorators that map a class to a route group and a method to an HTTP POST endpoint.
@Body() input
Nest extracts the HTTP request body into a method parameter. A DTO describes the expected data shape.
bcrypt.hashSync(...)
Computes a password hash synchronously and owns the main JavaScript thread until it returns.
cost = 12
The bcrypt work factor; each next step increases computation exponentially, so it must be selected from measurements.
await bcrypt.hash(...)
The asynchronous version delegates expensive native work to a thread pool and returns a Promise with the hash.
constructor(private users: UsersService)
Nest constructor injection: the IoC container creates UsersService and supplies it to the controller.
WHY THE CORRECTION WORKS

The fix preserves Event Loop responsiveness; it does not create infinite capacity. Control admission rate, pool saturation, and the number of concurrent expensive registrations.

WHAT PRODUCTION SHOWED
  • Event Loop delay rises together with registration traffic.
  • Even /health responds slowly while the database remains healthy.
  • CPU is high and the number of active requests grows in waves.
07 · DO NOT CONFUSE

Common misconceptions

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

MYTH

Ready callbacks execute simultaneously.

ACTUALLY

Readiness grants the right to wait, not parallel JavaScript execution.

MYTH

All queues are globally strict FIFO.

ACTUALLY

FIFO may apply inside a structure, while work categories follow different priority rules.

MYTH

Timer delay means the clock is inaccurate.

ACTUALLY

It usually indicates a busy stack, a saturated phase, or process load.

MYTH

Five expired timers mean five parallel executors.

ACTUALLY

They only become candidates for execution; one main JavaScript stack still processes callbacks sequentially.

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 does callback #2 receive roughly 260 ms of lag?
  2. Can a Promise inside callback #1 run before callback #2?