CHAPTER 01
DEEP DIVE · FROM BASICS TO CODE

Understanding: Event Loop order

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 Event Loop is the mechanism that lets one JavaScript thread coordinate many tasks. Synchronous code runs now, while timers, network and file operations, and other asynchronous work are registered and return callbacks when ready. The Event Loop repeatedly takes ready work from the appropriate queues and phases, allowing Node to handle many events without creating a separate JavaScript thread for every one of them.

TECHNICAL FOUNDATION

Main-thread Node JavaScript follows run-to-completion: another callback cannot interrupt a running section. After an ordinary callback, Node drains process.nextTick first and then the V8 microtask queue—Promise.then and queueMicrotask—before continuing or advancing the loop. A timer, I/O callback, or setImmediate can start only when ready and when the loop reaches its timers, poll, or check context.

Why it mattersPrediction needs three questions: where is this code running, which queue or phase owns the callback, and is its source ready? Registration order settles the tie only after those checks—usually inside one ready FIFO queue.
WHERE THE WORK RUNS
01YOUR JS CODEfunctions · callbacks
02NODE APIsfs · crypto · timers
03V8 + LIBUVheap · loop · pool
04OPERATING SYSTEMI/O · threads · memory
Anchor model

Execution starts are chosen at boundaries

A source line registers a future function. That function can begin only after current JavaScript finishes and its queue context allows it.

  1. 01 · StackCurrent JavaScript finishes

    A function or callback runs until return. No timer, Promise, or I/O callback can splice itself into the middle of synchronous work.

  2. 02 · CheckpointPriority work is checked

    After an ordinary callback, Node drains nextTick and then Promise/queueMicrotask. Work added by them may also join this checkpoint.

  3. 03 · Event LoopThe current or next phase continues

    Node takes another ready callback from the phase or moves through poll, check, and timers according to the current loop iteration.

Separate Node queueprocess.nextTick

Normally drains before V8 microtasks and can cause starvation when recursively refilled.

V8 microtask queuePromise / queueMicrotask

Runs at a checkpoint before phases continue; newly-added microtasks are drained too.

TimerssetTimeout

Delay is a minimum readiness threshold, not an exact callback start time.

PollI/O callback

Starts after the operation is ready, poll can process it, and the stack is free.

ChecksetImmediate

Waits for check and cannot interrupt a timer or I/O callback that is already running.

Why the 1 → 6 ladder is inaccurate

Sync really is first, and nextTick/microtasks receive a checkpoint. But timers, I/O, and immediates are not three positions in a global queue: their relative starts depend on phase, readiness, and registration location.

Where source order still matters

Callbacks in one already-ready FIFO queue are normally taken in insertion order. Do not extend that rule to different queues, different I/O sources, or merely equal delay values.

Two ready timer callbacks in one timers phasetimer 1 → nextTick from timer 1 → Promise from timer 1 → timer 2 → setImmediate from timer 1

A checkpoint occurs after timer 1, so priority work can appear between two callbacks of one phase. setImmediate waits for check and does not splice into the timers phase.

Runtime comparison

The Browser Event Loop and Node Event Loop are related, not identical

Both runtimes execute JavaScript and Promise jobs under ECMAScript rules, while the host determines where future work comes from. A browser integrates JavaScript with page events and rendering; Node integrates it with I/O and libuv phases.

Shared core

One JavaScript callback does not preempt another callback

One JavaScript agent follows run-to-completion. A ready timer, network response, or message does not start immediately: the current stack must become free and the host must select that work.

  • Promise reactions and queueMicrotask run as microtasks at a checkpoint.
  • Long synchronous work delays both ordinary callbacks and microtasks in that agent.
  • Parallel JavaScript requires another agent: a Web Worker, Worker Thread, or process.
Browser host

Tasks, microtasks, and rendering opportunities

The main browser event loop coordinates page JavaScript, user events, and rendering. It is not a libuv phase loop or one global FIFO queue.

Tasks
Scripts, clicks, timers, and other host events queue tasks through different task sources. Ordering is preserved inside a source, while the browser selects among runnable queues.
Microtasks
After a task, the browser performs a microtask checkpoint. Promise.then, queueMicrotask, and MutationObserver can drain before the next task.
Rendering
At a rendering opportunity the browser may update style and layout, call requestAnimationFrame before paint, and render a frame. It need not render after every task, and background tabs may be throttled.
Web Worker
A worker has its own global scope and event loop, cannot directly access the DOM, and communicates through messages. Heavy work leaves the page stack free but still competes for CPU.
Node.js host

Node queues and libuv phases without rendering

Node connects V8 to file systems, networking, processes, and libuv. A server runtime has no DOM, layout, paint, or standard requestAnimationFrame.

libuv phases
The loop passes timers, pending callbacks, internal idle/prepare, poll, check, and close callbacks. setImmediate belongs to check; ready I/O callbacks are commonly processed around poll.
nextTick
process.nextTick is a Node queue, not a libuv phase or browser standard. After an ordinary callback it normally drains before V8 microtasks; recursive refilling can delay I/O.
I/O and pool
Sockets usually wait on OS readiness, while parts of fs, DNS, crypto, and zlib use the bounded libuv thread pool. A completed result still posts a callback to the JavaScript Event Loop.
Worker Thread
A Worker Thread creates a separate V8 isolate, stack, and Event Loop inside the process. It suits CPU-bound JavaScript; messages back to main become asynchronous callbacks.
One principle, two hosts

The callback finishes first; then each host applies its own rules

Both snippets show run-to-completion and a microtask checkpoint. Their different APIs are host facilities, not features of the JavaScript language itself.

Browser · click handler
button.addEventListener('click', () => {
  console.log('handler');

  queueMicrotask(() => console.log('microtask'));
  requestAnimationFrame(() => console.log('rAF'));
  setTimeout(() => console.log('timer'), 0);
});

The handler runs first, followed by the microtask after it returns. rAF runs before a selected frame is painted; the timer is a separate future task. There is no portable total order between rAF and the timer.

Node · inside an I/O callback
readFile(new URL(import.meta.url), () => {
  console.log('I/O');

  process.nextTick(() => console.log('nextTick'));
  queueMicrotask(() => console.log('microtask'));
  setImmediate(() => console.log('immediate'));
  setTimeout(() => console.log('timer'), 0);
});

In this I/O context: I/O → nextTick → microtask → immediate → timer. The immediate reaches the next check phase; the new timer waits for a later timers context.

  • Promise and queueMicrotask follow the shared JavaScript jobs model, while the host integrates their checkpoint.
  • requestAnimationFrame describes browser rendering; setImmediate and process.nextTick are Node-specific APIs.
  • Web Workers and Worker Threads solve a similar isolation problem through different APIs and environments.
01 · GLOSSARY

Terms used in this experiment

Understand the words first, then the execution order.

01

Call Stack

The functions executing right now. No other callback starts JavaScript while this stack is busy.

02

Callback

A function the runtime invokes later after a timer, I/O completion, worker message, or another event.

03

Microtask

A high-priority Promise or queueMicrotask continuation, drained between callbacks and phases.

04

Phase

A stage of the libuv Event Loop. This lab focuses on timers, poll, and check.

05

Registration

The synchronous moment when runtime receives a callback and the conditions for running it later. Registration is not callback execution.

02 · MECHANICS

What happens step by step

Each step maps to an observable runtime state.

  1. 01
    Synchronous code runs

    Lines are read top to bottom: console.log prints now, while the remaining calls register callbacks.

  2. 02
    The stack becomes empty

    Only now can another callback begin JavaScript. This is a selection boundary, not preemption of the current function.

  3. 03
    A priority checkpoint runs

    After an ordinary callback Node drains process.nextTick and then Promise.then/queueMicrotask. Top-level ESM and an already-running microtask are separate contexts.

  4. 04
    Ready phase work is selected

    Timers checks reached thresholds, poll handles ready I/O, and check runs setImmediate. There is no universal global FIFO between them.

  5. 05
    The rule repeats after a callback

    Every completed callback is followed by another checkpoint. nextTick and Promise from timer 1 can therefore start before timer 2.

03 · CONTEXT

Where the result needs context

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

01

Source order is registration order

The nextTick line really executes before the Promise line, but their arrow-function bodies run later. Source order alone is not the final console.log order.

02

FIFO applies after context checks

Two Promise.then callbacks registered in sequence preserve their order. Two already-ready callbacks of one phase are also normally taken in queue order. This is not a rule for different phases, I/O sources, or readiness times.

03

Timers, immediates, and I/O are not a ladder

In the main module, setTimeout(0) and setImmediate may swap. If both are created inside one I/O callback, setImmediate runs before the new timer. I/O itself starts when its operation is ready and the loop can process its callback.

04

nextTick → Promise also needs context

After an ordinary callback and in CommonJS, nextTick precedes Promise microtasks. An ES module is evaluated as a microtask; scheduling at top-level ESM or inside another microtask can let Promise/queueMicrotask run before nextTick until control returns to Node.

05

Node version affects old diagrams

Since libuv 1.45 / Node 20, timers run only after poll instead of both before and after it. An old article’s diagram may therefore differ from modern Node.

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
console.log('sync'); // Runs now

// These lines register callbacks from top to bottom:
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));

// Their bodies run later according to queue and phase rules.
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
scenario117 lines
import { readFile } from 'node:fs';
import { fileURLToPath } from 'node:url';

const packageJsonPath = fileURLToPath(
  new URL('../package.json', import.meta.url),
);

const sleep = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));

async function eventLoopOrder(emit) {
  emit('call-stack', 'sync', 'Синхронный код начал выполняться');

  const outerCallbacks = [];
  const waitForOuter = new Promise((resolve) => {
    let completed = 0;
    const done = () => {
      completed += 1;
      if (completed === 4) resolve();
    };

    process.nextTick(() => {
      outerCallbacks.push('process.nextTick');
      emit('nextTick', 'callback', 'process.nextTick callback');
      done();
    });

    Promise.resolve().then(() => {
      outerCallbacks.push('Promise.then');
      emit('microtasks', 'callback', 'Promise.then microtask');
      done();
    });

    queueMicrotask(() => {
      outerCallbacks.push('queueMicrotask');
      emit('microtasks', 'callback', 'queueMicrotask callback');
      done();
    });

    setTimeout(() => {
      outerCallbacks.push('setTimeout(0)');
      emit('timers', 'callback', 'setTimeout(0) callback');
      done();
    }, 0);

    setImmediate(() => {
      outerCallbacks.push('setImmediate');
      emit('check', 'callback', 'setImmediate callback');
      done();
    });
  });

  emit(
    'call-stack',
    'schedule',
    'Callbacks зарегистрированы; синхронный стек сейчас освободится',
  );
  await waitForOuter;

  // Ждём timer и immediate. Начальный порядок зависит от того, из какого
  // async-контекста вызывается сценарий, поэтому мы записываем наблюдение,
  // а не подгоняем его под заученную последовательность.
  while (
    !outerCallbacks.includes('setTimeout(0)') ||
    !outerCallbacks.includes('setImmediate')
  ) {
    await sleep(1);
  }

  emit('result', 'result', `Контекст запуска: ${outerCallbacks.join(' → ')}`);

  emit('poll', 'schedule', 'Запускаем fs.readFile и переходим к I/O-раунду');
  await new Promise((resolve, reject) => {
    readFile(packageJsonPath, 'utf8', (error) => {
      if (error) {
        reject(error);
        return;
      }

      emit('poll', 'callback', 'Callback fs.readFile: сейчас мы внутри poll-фазы');
      const ioOrder = [];
      let completed = 0;

      const done = () => {
        completed += 1;
        if (completed === 4) {
          emit('result', 'result', `Внутри I/O: ${ioOrder.join(' → ')}`);
          resolve();
        }
      };

      process.nextTick(() => {
        ioOrder.push('nextTick');
        emit('nextTick', 'callback', 'nextTick, созданный внутри I/O');
        done();
      });

      Promise.resolve().then(() => {
        ioOrder.push('Promise');
        emit('microtasks', 'callback', 'Promise, созданный внутри I/O');
        done();
      });

      setImmediate(() => {
        ioOrder.push('setImmediate');
        emit('check', 'callback', 'setImmediate, созданный внутри I/O');
        done();
      });

      setTimeout(() => {
        ioOrder.push('setTimeout');
        emit('timers', 'callback', 'setTimeout(0), созданный внутри I/O');
        done();
      }, 0);
    });
  });
}

The live trace is application-level scenario instrumentation. Rows and timestamps are recorded when emit(...) actually runs inside callbacks, so the observed order belongs to a real execution. The scenario itself supplies source and lane labels: this is not a V8/libuv profiler or a direct view of their internal queues and phases.

06 · RECIPES

Practical patterns worth keeping nearby

Compare the goal, code, and caveats instead of memorizing syntax without a model.

01

Microtasks between two timers

See the checkpoint after every callback, not only after the entire timers phase.

setTimeout(() => {
  console.log('timer 1');

  process.nextTick(() => console.log('nextTick from timer 1'));
  Promise.resolve().then(() => console.log('Promise from timer 1'));
  setImmediate(() => console.log('immediate from timer 1'));
}, 0);

setTimeout(() => console.log('timer 2'), 0);
  • If both timers are ready in one timers phase: timer 1 → nextTick → Promise → timer 2 → immediate.
  • nextTick and Promise start after the timer 1 callback returns.
  • setImmediate waits for check, so it does not interrupt timers processing.
02

Immediate and timer inside I/O

Pin down a context where the relative order is predictable.

import { readFile } from 'node:fs';

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log('timer'), 0);
  setImmediate(() => console.log('immediate'));
});

// Here: immediate → timer
  • The readFile callback is handled in an I/O/poll context.
  • After poll, the loop reaches check, so the new immediate runs before the new zero-delay timer.
  • Do not blindly transfer this result to a top-level main module.
07 · 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

An order event is published before the required audit record

A checkout endpoint stores an order, schedules event publishing with setImmediate, and then waits for the audit write. The author assumed that source-code order also guaranteed the order of every side effect.

INCIDENT CONTEXT

After await, the current stack is released. While audit.write is waiting on I/O, the check phase may run setImmediate, so a downstream consumer can observe OrderCreated before the required audit record exists.

BEFOREPROBLEMATIC IMPLEMENTATION
async function checkout(input) {
  const order = await orders.insert(input);

  setImmediate(() => {
    broker.publish('OrderCreated', order);
  });

  await audit.write('order.created', order.id);
  return order;
}

Source order defines when work is registered, but the setImmediate callback and the await continuation use different scheduling mechanisms. There is no business ordering guarantee between them.

AFTERCORRECTED IMPLEMENTATION
async function checkout(input) {
  const order = await orders.insert(input);

  await audit.write('order.created', order.id);
  await broker.publish('OrderCreated', order);

  return order;
}

// If INSERT and event creation must be atomic,
// write the order and an outbox row
// in the same database transaction.

The required dependency is expressed with await. When the order and event must succeed together, a transactional outbox provides that guarantee instead of relying on Event Loop phases.

FUNCTIONS AND CONSTRUCTS

What the unfamiliar calls from both code samples actually do.

orders.insert(input)
An asynchronous repository method: writes the order to the database and returns a Promise with the created order.
setImmediate(callback)
Registers a callback for the Event Loop check phase. It does not mean “run strictly after the next source line.”
await promise
Pauses only the current async function. After settlement, its continuation is queued as a microtask.
broker.publish(...)
Sends an event to a message broker. A well-designed API returns a Promise so the caller can await acknowledgement.
transactional outbox
Stores the order and a future-event row in one DB transaction; a separate publisher sends the event later.
WHY THE CORRECTION WORKS

The Event Loop decides when a callback gets the stack; it should not encode business sequencing. Chain dependent effects explicitly and run only truly independent operations concurrently.

WHAT PRODUCTION SHOWED
  • OrderCreated appears in the broker before the matching audit row.
  • Rare race failures become reproducible when audit storage is artificially delayed.
  • The integration test becomes flaky only under I/O load.
08 · DO NOT CONFUSE

Common misconceptions

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

MYTH

Node has one global event queue.

ACTUALLY

There are multiple queues and phases with different priority rules.

MYTH

setTimeout(fn, 0) runs fn immediately.

ACTUALLY

Zero is a minimum delay; the callback still waits for its phase and a free stack.

MYTH

Async code can interrupt current JavaScript.

ACTUALLY

A callback starts only after the current JavaScript finishes.

MYTH

A callback from the higher source line must run first.

ACTUALLY

That only holds under compatible rules, such as one FIFO queue. Different queues apply their priorities and phase rules first.

MYTH

process.nextTick is always before Promise.

ACTUALLY

That is the normal callback and CommonJS order. During top-level ESM evaluation, Promise/microtasks can get ahead.

09 · 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 the Promise callback not run inside Promise.resolve()?
  2. What happens to a timer while the current function runs for five seconds?
  3. When does source order remain callback order, and when does a queue override it?