Node.js Event Loop Explained


Node.js is single-threaded, meaning it executes JavaScript code in sequence on a single main thread. Yet, it’s famous for handling thousands of concurrent operations without slowing down. The secret behind this magic?
The Event Loop, the core mechanism that lets Node.js handle asynchronous operations (like network requests, file system reads, or timers) without blocking the main thread.
Why should I understand event loop?
The Event Loop is at the heart of Node.js’s asynchronous programming model.
Understanding how it works and how callbacks, promises, and async/await fit into it gives you the power to write faster, more predictable, and bug-free asynchronous code. Without this understanding, you risk introducing hard-to-debug race conditions, blocking the thread, or misusing async patterns.
Analogy: A One-Server Restaurant
Imagine a small restaurant with one server who handles multiple responsibilities:
Taking orders from customers
Informing the kitchen about orders
Delivering food when it's ready
Here's how it works:
Customer 1 arrives: Server gives them a menu and takes their pizza order.
Server informs kitchen: Kitchen acknowledges and starts preparation.
Customer 2 arrives: Instead of waiting for the pizza, server immediately takes their biryani order.
Kitchen works in parallel: Both orders are being prepared simultaneously.
Pizza ready: Server delivers it to Customer 1.
Biryani ready: Server delivers it to Customer 2.
This cycle repeats for every customer.
The server never waits idle. They efficiently manage multiple customers while food is prepared in the background. That’s exactly how Node.js stays responsive.
In Node.js terms:
Server = Event Loop (single-threaded)
Kitchen staff = libuv thread pool + OS kernel
Orders = Asynchronous tasks
Order notes = Event queues
The continuous process = The Event Loop cycle
The Event Loop
The event loop allows Node.js to be single-threaded while still handling many concurrent operations. Instead of waiting for a task (like a database query or file read) to finish, Node.js offloads it to the operating system and moves on to the next task. When the offloaded task is complete, a callback function is put into the event queue. The event loop constantly monitors this queue and, when the call stack is empty, it moves the callback from the queue to the stack to be executed.
Here's the step-by-step process:
Node.js starts: Executes your initial script synchronously
Encounters async operations: When it finds
setTimeout
,fs.readFile
, etc., Node.js:Delegates the operation to libuv or the OS
Registers the callback function
Continues executing the next synchronous code
Operations complete: When async operations finish, their callbacks are placed in appropriate queues
Event loop activates: Once the call stack is empty, the event loop:
Processes all microtasks first
Then processes macrotasks phase by phase
Repeats this cycle continuously
Key Rule: The event loop only processes queued callbacks when the call stack is completely empty.
Event Loop Phases
┌───────────────────────────┐
┌─>│ timers │ <- setTimeout, setInterval callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ <- TCP/UDP connection callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ <- Internal Node.js operations
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │ <- Most I/O operations
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ <- setImmediate callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ <- Socket.on('close'), cleanup
└───────────────────────────┘
1. Timers Phase
Executes callbacks scheduled by
setTimeout()
andsetInterval()
Only runs callbacks whose timer has expired
2. Pending Callbacks Phase
- Handles callbacks for completed TCP/UDP socket connections
3. Idle, Prepare Phase
Internal Node.js operations
Not directly relevant to application code
4. Poll Phase (Most Important)
Fetches new I/O events (like file reads, network requests, and database queries) and executes their callbacks
This is where most application code runs
If poll queue is empty:
Checks for
setImmediate()
callbacks → moves to check phaseOtherwise, waits for incoming callbacks
5. Check Phase
Executes
setImmediate()
callbacksRuns immediately after poll phase if poll becomes idle
6. Close Callbacks Phase
Handles cleanup callbacks (e.g.,
socket.on('close')
)Ensures proper resource cleanup
Important: After each phase, all microtasks are processed before moving to the next phase.
Microtasks
The Node.js event loop has two main queues for asynchronous tasks:
Microtask Queue: This queue holds higher-priority tasks.
process.nextTick()
,queueMicrotask()
and promises (.then()
,.catch()
,.finally()
) are the primary examples of microtasks. The event loop empties the entire microtask queue after each phase of the event loop and before moving to the next one.Macrotask Queue: This queue holds lower-priority tasks. Examples include
setTimeout
,setImmediate
, and I/O callbacks. The event loop processes one macrotask per cycle.
Because of this priority system, if you have a process.nextTick
and a setTimeout
scheduled at the same time, the nextTick
callback will always run first. This behavior makes nextTick
useful for tasks that need to be "immediately" asynchronous.
Event loop executes tasks in process.nextTick queue
first, and then executes promises microtask queue
, and then executes macrotask queue
.
Execution Order Example
const baz = () => console.log('baz');
const foo = () => console.log('foo');
const zoo = () => console.log('zoo');
const start = () => {
console.log('start'); // 1. Synchronous
setImmediate(baz); // 5. Macrotask (check phase)
new Promise((resolve, reject) => {
resolve('bar'); // 2. Promise resolves synchronously
}).then(resolve => {
console.log(resolve); // 3. Microtask (promise)
process.nextTick(zoo); // 4. Microtask (nextTick)
});
process.nextTick(foo); // 2. Microtask (nextTick)
};
start();
// OUTPUT:
// start ← Synchronous execution
// foo ← process.nextTick (highest priority microtask)
// bar ← Promise then callback
// zoo ← process.nextTick (added during promise callback)
// baz ← setImmediate (macrotask, runs after all microtasks)
Execution Timeline:
console.log('start')
runs immediately (synchronous)setImmediate(baz)
is scheduled for check phasePromise resolves and
.then()
callback is queued as microtaskprocess.nextTick(foo)
is queued as microtaskCall stack becomes empty → Event loop starts
All microtasks run:
foo
, then promise callback (bar
), thenzoo
Move to check phase and run
baz
Note: The principle aforementioned holds true in CommonJS cases, but keep in mind in ES Modules, e.g.
mjs
files, the execution order will be different. The ES Module being loaded is wrapped as an asynchronous operation, and thus the entire script is actually already in thepromises microtask queue
.
What is libuv?
libuv is a cross-platform C library that provides the core functionality for Node.js's asynchronous, non-blocking I/O operations. In simple terms, it's the "engine" that powers the Node.js event loop. It was primarily developed for use by Node.js.
libuv allows Node.js to perform I/O operations (like reading files, making network requests, and interacting with the file system) without blocking the main thread. It does this by offloading these tasks to the operating system or a thread pool, and then a callback is triggered once the operation is complete.
https://docs.libuv.org/en/v1.x/
Summary
The Node.js event loop is a sophisticated system that enables single-threaded JavaScript to handle thousands of concurrent operations efficiently. Key takeaways:
Single-threaded: JavaScript runs on one thread, but I/O operations are handled asynchronously
Event-driven: Callbacks are queued and processed when the call stack is empty
Phase-based: The event loop processes different types of callbacks in specific phases
Priority system: Microtasks always run before macrotasks
Non-blocking: Properly written async code never blocks the event loop
libuv-powered: The C library handles the heavy lifting of async operations
Understanding the event loop empowers you to write faster, more predictable Node.js applications. The key is to embrace asynchronous patterns and avoid blocking operations that could slow down your entire application.
Remember: The event loop constantly orchestrates the flow of tasks between the Call Stack, Event Queues, and Microtask Queue, powered by libuv's ability to handle I/O asynchronously. This ensures that a single-threaded process can efficiently manage multiple operations without getting blocked.
Subscribe to my newsletter
Read articles from Dineshraj Anandan directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
