Skip to main content
The event loop is the heart of Node.js’s non-blocking I/O model. It enables JavaScript to perform asynchronous operations despite being single-threaded by offloading operations to the system kernel or thread pool whenever possible.

Event Loop Overview

The event loop continuously processes callbacks and executes JavaScript code. It follows a specific set of phases, each with its own queue of callbacks to execute.
   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

Event Loop Phases

Each phase has a FIFO queue of callbacks to execute. The event loop will process all callbacks in a phase’s queue before moving to the next phase.
1

Timers Phase

Executes callbacks scheduled by setTimeout() and setInterval(). Timers are checked to see if their threshold has elapsed.
2

Pending Callbacks Phase

Executes I/O callbacks deferred to the next loop iteration. These are callbacks for some system operations like TCP errors.
3

Idle, Prepare Phase

Internal phase used only by Node.js internally. Not accessible to user code.
4

Poll Phase

Retrieves new I/O events and executes I/O-related callbacks. This is where most application code executes.
5

Check Phase

Executes callbacks scheduled by setImmediate(). Always runs after the poll phase.
6

Close Callbacks Phase

Executes close callbacks like socket.on('close', ...).

Timers Phase

Timers schedule callbacks to be executed after a minimum threshold in milliseconds.

Timer Implementation

Node.js uses a priority queue to manage timers efficiently:
// From lib/internal/timers.js
const {
  timerListMap,
  timerListQueue,
  insert,
} = require('internal/timers');

function setTimeout(callback, after, ...args) {
  after = Math.max(1, after);
  const timeout = new Timeout(callback, after, args, false, true);
  insert(timeout, timeout._idleTimeout);
  return timeout;
}
Timers with the same timeout duration are grouped into a linked list and stored in a priority queue. This optimization reduces the overhead of managing thousands of timers.

Timer Precision

// From lib/timers.js
const MathTrunc = Math.trunc;

// Timers are truncated to milliseconds
const msecs = MathTrunc(item._idleTimeout);
Timers are not guaranteed to execute at the exact specified time. They execute after at least the specified delay. Heavy computation in other phases can delay timer execution.

Example: Timer Execution

setTimeout(() => {
  console.log('Timer 1');
}, 100);

setTimeout(() => {
  console.log('Timer 2');
}, 100);

// Both timers are grouped in the same timer list
// They execute in the order they were scheduled

Poll Phase

The poll phase has two main functions:
  1. Calculate how long it should block and poll for I/O
  2. Process events in the poll queue

Poll Phase Behavior

The event loop will iterate through its queue of callbacks, executing them synchronously until either:
  • The queue has been exhausted
  • The system-dependent hard limit is reached
One of two things will happen:
  1. If scripts have been scheduled by setImmediate(), the event loop will end the poll phase and continue to the check phase
  2. If no setImmediate() is scheduled, the event loop will wait for callbacks to be added to the queue, then execute them immediately

Poll Phase Timeout

The poll phase calculates how long it should wait:
// From libuv - simplified concept
int timeout = calculate_timeout();
uv_run(loop, UV_RUN_ONCE); // Block for up to timeout ms
The timeout is based on:
  • Timers scheduled to execute soon
  • Pending setImmediate() callbacks
  • Other phase requirements

setImmediate() vs setTimeout()

These two functions are similar but behave differently depending on the context.

Check Phase vs Timers Phase

// From lib/timers.js
const { Immediate } = require('internal/timers');

function setImmediate(callback, ...args) {
  return new Immediate(callback, args);
}

Execution Order Example

// Order is non-deterministic when called from main module
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
The order of execution depends on the performance of the process. If the event loop enters the timer phase before 1ms has elapsed, setImmediate() executes first.

Within an I/O Cycle

const fs = require('fs');

fs.readFile(__filename, () => {
  // Inside an I/O callback (poll phase)
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  
  setImmediate(() => {
    console.log('immediate');
  });
});

// Output (always):
// immediate
// timeout
setImmediate() will always execute before setTimeout() when scheduled within an I/O callback, because the check phase comes immediately after the poll phase.

process.nextTick()

process.nextTick() is not technically part of the event loop. It has its own queue that is processed after the current operation completes, regardless of the current phase.

nextTick Queue

The nextTick queue is processed:
  • After each phase of the event loop
  • After each operation in the current phase
// From lib/internal/process/task_queues.js
function nextTick(callback, ...args) {
  const asyncId = newAsyncId();
  const tickObject = {
    callback,
    args,
    asyncId,
  };
  queue.push(tickObject);
  if (queue.length === 1) {
    runNextTicks();
  }
}

Execution Order

setImmediate(() => console.log('setImmediate'));

process.nextTick(() => console.log('nextTick'));

Promise.resolve().then(() => console.log('Promise'));

console.log('synchronous');

// Output:
// synchronous
// nextTick
// Promise
// setImmediate
Recursive process.nextTick() calls can starve the event loop, preventing I/O operations from executing. Use setImmediate() for recursive operations.

Why nextTick Exists

const EventEmitter = require('events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();
    // Allow users to register listeners before emitting
    process.nextTick(() => {
      this.emit('event');
    });
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => console.log('Event fired!'));
// Without nextTick, the event would fire before the listener is registered

Microtasks vs Macrotasks

Node.js distinguishes between two types of asynchronous tasks:

Microtasks

Executed immediately after the currently executing script and before returning to the event loop:
  • process.nextTick() callbacks (highest priority)
  • Promise callbacks (.then(), .catch(), .finally())
  • queueMicrotask() callbacks

Macrotasks

Scheduled in event loop phases:
  • setTimeout() / setInterval() (timers phase)
  • setImmediate() (check phase)
  • I/O operations (poll phase)
  • UI rendering (in browsers, not Node.js)

Processing Order

setImmediate(() => console.log('1: setImmediate'));

Promise.resolve().then(() => {
  console.log('2: Promise');
  process.nextTick(() => console.log('3: nextTick inside Promise'));
});

process.nextTick(() => {
  console.log('4: nextTick');
  Promise.resolve().then(() => console.log('5: Promise inside nextTick'));
});

// Output:
// 4: nextTick
// 2: Promise
// 3: nextTick inside Promise
// 5: Promise inside nextTick
// 1: setImmediate
All microtasks are processed before moving to the next phase of the event loop. Within microtasks, process.nextTick() has priority over Promises.

Close Callbacks Phase

Handles cleanup when resources are closed:
const net = require('net');

const server = net.createServer();
server.on('close', () => {
  console.log('Server closed');
  // Executed in close callbacks phase
});

server.listen(8080);
server.close();

Internal Implementation

// From src/stream_base.cc - simplified
void StreamBase::OnStreamClose() {
  // Queue close callback for close callbacks phase
  MakeCallback(env->onclose_string(), 0, nullptr);
}

Event Loop in Practice

Blocking the Event Loop

// BAD: Blocks the event loop
const start = Date.now();
while (Date.now() - start < 5000) {
  // Busy loop - nothing else can run
}

// GOOD: Allow event loop to process other tasks
function processChunk(data, callback) {
  setImmediate(() => {
    // Process chunk
    if (moreData) {
      processChunk(nextChunk, callback);
    } else {
      callback();
    }
  });
}
Use setImmediate() or process.nextTick() to break up CPU-intensive operations and allow the event loop to process I/O events.

Monitoring Event Loop

const { performance } = require('perf_hooks');

const obs = new PerformanceObserver((list) => {
  const entry = list.getEntries()[0];
  console.log(`Event loop lag: ${entry.duration}ms`);
});

obs.observe({ entryTypes: ['measure'] });

setInterval(() => {
  const start = performance.now();
  setImmediate(() => {
    const lag = performance.now() - start;
    performance.measure('event-loop-lag', {
      start,
      duration: lag,
    });
  });
}, 1000);