- Node.js
- Architecture
- Advanced
- Deep Dive
Node.js Event Loop Explained with Phases
How the Node.js event loop works: its six phases, where process.nextTick and promises run, what libuv's thread pool does, and how to avoid blocking.
The Node.js event loop is the mechanism that lets a single JavaScript thread handle many concurrent operations: slow work such as network and file I/O is handed off, and its callbacks run later when results are ready. Each turn of the loop moves through six phases (timers, pending callbacks, idle/prepare, poll, check and close callbacks), and the process.nextTick and promise microtask queues are drained in between.
Key takeaways
- Your JavaScript runs on one thread, but Node.js is not "doing everything on one thread": libuv, the C library under Node.js, uses the operating system and a small thread pool for I/O.
- One loop iteration visits six phases in a fixed order. Timers run in the timers phase, most I/O callbacks in the poll phase and
setImmediate()in the check phase. process.nextTick()callbacks and promise callbacks are not phases. They run as soon as the current operation finishes, before the loop moves on.- Any long synchronous task, such as a big
JSON.parseor a CPU-heavy loop, blocks every other request. Move that work to Worker Threads.
The restaurant analogy: one waiter, many cooks
Imagine a restaurant with one waiter (your JavaScript thread) and a kitchen full of cooks (the operating system and libuv's thread pool).
The blocking model
The waiter takes Table 1's order, walks to the kitchen and stands there for 20 minutes while the steak cooks. Only after serving Table 1 does he go to Table 2. Table 2 waits the whole time just because Table 1 ordered something slow. This is how a thread behaves when it waits on blocking I/O.
The non-blocking model
The waiter takes Table 1's order, pins the ticket in the kitchen and immediately takes Table 2's and Table 3's orders. When a cook rings the bell ("Order up!"), the waiter delivers that dish and carries on.
One waiter can look after many tables this way, as long as his own job is taking orders and carrying plates (I/O) rather than cooking (CPU work). The bell is the event, the dish is the callback, and the waiter's routine of checking for ready dishes is the event loop.
What runs where: V8, libuv and the thread pool
Node.js is built on two main components:
- V8 is the JavaScript engine. It executes your code on the main thread.
- libuv is a C library that provides the event loop and asynchronous I/O.
When you call an asynchronous API, Node.js starts the operation and your code continues with the next line. How the work actually gets done depends on the operation:
- Network I/O (TCP, HTTP sockets) uses the operating system's non-blocking mechanisms such as epoll, kqueue or IOCP. No extra thread waits for each socket.
- Some operations use libuv's thread pool (4 threads by default, configurable with
UV_THREADPOOL_SIZE). The Node.js guide Don't Block the Event Loop lists them: asynchronousfsAPIs,dns.lookup(), severalcryptofunctions such ascrypto.pbkdf2()andcrypto.scrypt(), and asynchronouszlibAPIs. The libuv thread pool documentation describes the pool itself.
When the work completes, its callback is queued, and the event loop runs it on the main thread when it reaches the right phase.
The six phases of the event loop
The official Node.js guide, The Node.js Event Loop, describes each iteration as a sequence of phases, each with its own queue of callbacks.
1. Timers
Runs callbacks scheduled with setTimeout() and setInterval() whose threshold has passed. A timer's delay is a minimum, not a guarantee: a 100 ms timer runs as soon as possible after 100 ms, but it can run later if the loop is busy.
2. Pending callbacks
Runs I/O callbacks that were deferred to the next loop iteration. The Node.js guide gives the example of some TCP errors, such as ECONNREFUSED on certain systems, whose callbacks are queued here.
3. Idle, prepare
Used internally by libuv. Your code never schedules anything in this phase.
4. Poll
Retrieves new I/O events and runs their callbacks, for example "data arrived on this socket" or "this file read finished". If the poll queue is empty, the loop either moves on to the check phase (when setImmediate() callbacks are waiting) or waits here for new I/O, up to the time when the next timer is due.
5. Check
Runs setImmediate() callbacks. Inside an I/O callback, setImmediate() always runs before a setTimeout(..., 0) scheduled at the same moment, because the check phase comes straight after poll.
6. Close callbacks
Runs close events such as socket.on('close', ...).
Microtasks: process.nextTick and promises
Two queues sit outside the phases and are processed whenever the current operation finishes:
- The
process.nextTick()queue runs first. - The promise microtask queue runs next. It holds
.then()callbacks and the code that resumes after anawait.
Both queues are drained completely before the event loop continues. That makes them powerful but dangerous: a function that keeps calling process.nextTick() recursively starves the loop, and I/O callbacks never get a turn.
A quick ordering test
Save this as order.js and run it with node order.js:
const fs = require('fs');
console.log('1: synchronous start');
setTimeout(() => console.log('5: setTimeout (timers phase)'), 0);
setImmediate(() => console.log('6: setImmediate (check phase)'));
Promise.resolve().then(() => console.log('4: promise microtask'));
process.nextTick(() => console.log('3: process.nextTick'));
console.log('2: synchronous end');
fs.readFile(__filename, () => {
// Inside an I/O callback, check always comes before the next timers phase
setTimeout(() => console.log('I/O: setTimeout'), 0);
setImmediate(() => console.log('I/O: setImmediate'));
});
Synchronous code prints first, then nextTick, then the promise. In the main module, the order of setTimeout(..., 0) and setImmediate() is not guaranteed and can change between runs. Inside the readFile callback, setImmediate always wins.
Don't block the event loop
JavaScript runs on one thread, so while a synchronous task is running, nothing else runs: no new requests, timers or I/O callbacks. In the analogy, the waiter has stopped serving and started cooking.
// BAD: blocks every other request while it runs
app.post('/data', (req, res) => {
const data = fs.readFileSync('huge-file.json'); // blocking file read
const json = JSON.parse(data); // CPU work on the main thread
res.send(`Parsed ${Object.keys(json).length} keys`);
});
The Node.js guide notes that JSON.parse and JSON.stringify are O(n) and "for large n they can take surprisingly long". Use fs.promises.readFile for the read. For truly CPU-heavy work, such as parsing huge payloads, image processing or custom hashing loops, move it off the main thread with Worker Threads:
const { Worker } = require('worker_threads');
app.post('/process-image', (req, res) => {
const worker = new Worker('./resize-worker.js', { workerData: req.body.image });
worker.once('message', (result) => res.send(result));
worker.once('error', (err) => res.status(500).send(err.message));
});
Try it in the browser
You can experiment with timers, promises and async/await in the WebFiddle Node.js sandbox. Be aware that the sandbox is a Web Worker with a Node.js API shim, not a real Node.js process, so it has no libuv phases. setImmediate() is not available there, and process.nextTick() is emulated with setTimeout(cb, 0), so nextTick callbacks run after promises and earlier timers. Use a local node install to check exact phase ordering.
To go further, read our practical guide to async/await in Node.js or see how we run Node.js code in the browser.
Frequently asked questions
Is Node.js single-threaded?
Your JavaScript runs on a single main thread, but Node.js as a whole is not single-threaded. libuv hands network I/O to the operating system and runs file system operations, dns.lookup(), some crypto functions and zlib on a thread pool. Worker Threads let you run additional JavaScript threads when you need them.
What are the phases of the Node.js event loop?
Each iteration runs six phases in order: timers for setTimeout and setInterval, pending callbacks for deferred I/O callbacks, idle and prepare for internal use, poll for new I/O events, check for setImmediate callbacks, and close callbacks for events like a socket closing. Microtasks are drained between operations throughout.
What is the difference between process.nextTick and setImmediate?
process.nextTick() runs its callback as soon as the current operation finishes, before the event loop continues and before promise callbacks. setImmediate() waits for the check phase of the loop, which comes after poll. Recursive nextTick calls can starve I/O, so prefer setImmediate() when you simply want to defer work.
Does setTimeout with 0 ms run immediately?
No. A zero-millisecond timer is queued for the timers phase and runs only after the current synchronous code and all pending microtasks have finished. The delay is a minimum rather than a promise, so a busy event loop can delay it further. Inside an I/O callback, setImmediate() always runs first.
How do I avoid blocking the event loop?
Keep synchronous work short. Use asynchronous APIs such as fs.promises instead of readFileSync in request handlers, split long loops into smaller chunks, and move CPU-heavy tasks like image processing or parsing very large JSON into Worker Threads or a separate service so other requests keep getting served.
WebFiddle