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 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.
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?