Deep dive into Node.js event loop phases, timers, microtasks, and the execution model that powers asynchronous I/O
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.
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.
Node.js uses a priority queue to manage timers efficiently:
// From lib/internal/timers.jsconst { 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.
// From lib/timers.jsconst MathTrunc = Math.trunc;// Timers are truncated to millisecondsconst 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.
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
// Order is non-deterministic when called from main modulesetTimeout(() => { 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.
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() 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.
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
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();
// From src/stream_base.cc - simplifiedvoid StreamBase::OnStreamClose() { // Queue close callback for close callbacks phase MakeCallback(env->onclose_string(), 0, nullptr);}