Callback queue
Five zero-delay timers show how one expensive callback delays the entire queue.
Timeline
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.
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.
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.
Terms used in this experiment
Understand the words first, then the execution order.
Queue
A waiting structure. Being queued does not mean the callback is currently executing.
Lag
Delay relative to an expected moment. Here the value is total time since registration, including the minimum timer threshold and stack waiting.
Run-to-completion
The current callback runs until it returns; the Event Loop does not preempt it.
Starvation
A priority queue keeps refilling and prevents other phases from receiving execution time.
What happens step by step
Each step maps to an observable runtime state.
- 01Five registrations
All setTimeout(0) calls are created in one short synchronous loop.
- 02Timers become ready
Their minimum deadline passes, but the stack may still be busy.
- 03First callback blocks
A CPU loop owns the only main JavaScript stack for about 260 ms.
- 04Priority queues run
nextTick and Promise from the first callback run before timer #2.
- 05The queue drains
The remaining callbacks execute quickly, one after another.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
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.
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.
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.
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.
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
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
if (i === 1) heavyCpuWork(260);
console.log('timer', i);
}, 0);
}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';
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.
How a learning mistake becomes an incident
A realistic service: the original code, observable failure, corrected implementation, and why the correction works.
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.
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.
@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.
@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.
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.
Common misconceptions
The myth is on the left; the accurate model is on the right.
Ready callbacks execute simultaneously.
Readiness grants the right to wait, not parallel JavaScript execution.
All queues are globally strict FIFO.
FIFO may apply inside a structure, while work categories follow different priority rules.
Timer delay means the clock is inaccurate.
It usually indicates a busy stack, a saturated phase, or process load.
Five expired timers mean five parallel executors.
They only become candidates for execution; one main JavaScript stack still processes callbacks sequentially.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why does callback #2 receive roughly 260 ms of lag?
- Can a Promise inside callback #1 run before callback #2?