Event Loop in Node.js: How It Works and Practical Examples
1. Overview
Event Loop is the core mechanism that helps Node.js handle thousands of requests simultaneously using just a single thread for JavaScript. Many of you write async/await, setTimeout, and Promise code daily but aren’t clear on how the actual execution order really works.
This article explains the Event Loop structure through specific phases, distinguishes between microtasks and macrotasks, and illustrates with runnable code examples. After reading, you’ll be able to accurately predict the log order of complex asynchronous code snippets and avoid common mistakes like blocking the event loop.
2. How Node.js Works Single-Threaded
Node.js runs JavaScript on a single main thread, but I/O operations (file reading, network calls, DB queries) are pushed down to libuv — a C library that handles asynchronous I/O using a thread pool or kernel mechanisms (epoll, kqueue…).
┌───────────────────────────┐
│ JavaScript (single thread) │
└──────────────┬────────────┘
│ calls fs.readFile, setTimeout, net...
▼
┌───────────────────────────┐
│ libuv (C/C++) │
│ - Thread pool │
│ - Event loop │
│ - OS async APIs │
└───────────────────────────┘
When an I/O operation completes, libuv puts the callback in the corresponding queue. The Event Loop continuously checks these queues and executes callbacks when it’s their turn — that’s the “loop”.
Note: Your JavaScript code always runs on a single thread. If you write heavy synchronous code (for example, a large computation loop), it will block the entire event loop, causing all other requests to wait.
3. Event Loop Phases
The Event Loop doesn’t run a simple loop but goes through 6 phases in a fixed order with each iteration (tick):
┌───────────────────────┐
┌─>│ timers │ setTimeout, setInterval
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ pending callbacks │ I/O callbacks delayed
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ idle, prepare │
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ poll │ I/O events (file read, network)
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ check │ setImmediate
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
└──┤ close callbacks │ socket.on('close')
└─────────────────────────┘
3.1. Brief Description of Each Phase
- timers: executes callbacks for
setTimeout/setIntervalthat have expired. - pending callbacks: executes I/O callbacks delayed from the previous cycle.
- poll: waits for and processes new I/O events (file reading complete, network response received…). This is the most time-consuming phase.
- check: executes callbacks from
setImmediate. - close callbacks: handles close events, for example
socket.on('close', ...).
Each phase has its own queue. The Event Loop processes all (or up to a limit) callbacks in the queue of the current phase before moving to the next phase.
4. Microtask Queue: Promise and process.nextTick
In addition to the 6 macrotask phases above, Node.js also has a microtask queue, which is processed between each phase — you don’t have to wait for a full loop cycle.
There are two types of microtasks:
process.nextTick()— its own queue, always processed before any other microtasks.Promise.then/catch/finally— the standard microtask queue (per ECMAScript spec).
console.log('1 - sync start');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - promise'));
process.nextTick(() => console.log('4 - nextTick'));
console.log('5 - sync end');
Explanation:
- The synchronous
console.loglines run immediately (1,5). setTimeoutpushes the callback into the timers queue — it runs in the next phase.- Both
process.nextTickandPromise.thenare microtasks, butnextTickhas higher priority. - After synchronous code finishes running, Node.js drains the
nextTickqueue, then drains thePromisemicrotask queue, before entering the timers phase.
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout
Note: If you call
process.nextTick()recursively (for example, another callback callsnextTickagain), the event loop will never move to the next phase — this is called “I/O starvation”.
5. setTimeout vs setImmediate: Which Order is Correct?
This is the most confusing question. The order between setTimeout(fn, 0) and setImmediate(fn) is not fixed if called at the top level, but is fixed if called inside an I/O callback.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
Because this code is in the callback of fs.readFile — meaning it’s in the poll phase — when poll finishes, the Event Loop always moves to the check phase (setImmediate) before the next loop iteration returns to the timers phase.
immediate
timeout
Conversely, if you place these two statements at the top level (outside any I/O callback), the order depends on the performance of the Node.js startup process at that moment — you should not rely on this order in production code.
6. Blocking the Event Loop: The Most Common Mistake
Because only one thread handles JavaScript, any long-running synchronous code blocks the entire server.
const express = require('express');
const app = express();
app.get('/fibonacci', (req, res) => {
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
res.send(`Result: ${fib(40)}`); // blocking!
});
app.listen(3000);
When a request calls /fibonacci, the fib(40) function runs synchronously and takes several seconds. During that time, every other request (even a simple ping) has to wait, because the event loop is stuck on one task.
6.1. Solution: Push Heavy Work to Worker Thread
const { Worker } = require('worker_threads');
app.get('/fibonacci', (req, res) => {
const worker = new Worker('./fib-worker.js', {
workerData: { n: 40 }
});
worker.on('message', (result) => res.send(`Result: ${result}`));
worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
Worker runs on a separate thread and doesn’t occupy the main thread. The event loop of the main request stays free to handle other requests while the worker computes.
When NOT to use Worker Thread: for I/O operations (database reads, API calls, file reads) — these are already asynchronous, no worker needed. Worker Thread is only useful for CPU-bound tasks (heavy computation, encryption, image processing…).
7. Measuring Event Loop Delay with perf_hooks
To detect event loop blocking in production, you can use the built-in perf_hooks module.
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log(`Event loop delay (mean): ${histogram.mean / 1e6} ms`);
histogram.reset();
}, 5000);
monitorEventLoopDelay creates a histogram measuring how much the event loop is “delayed” compared to the ideal schedule. If the mean value increases abnormally (for example, above 100ms), it’s a sign that some code is blocking somewhere.
8. Writing Tests to Verify Execution Order
To confirm your understanding of the microtask/macrotask order, you can write a test that records logs in an array and then asserts the order.
// order.js
function recordOrder(log) {
log.push('sync-start');
setTimeout(() => log.push('timeout'), 0);
Promise.resolve().then(() => log.push('promise'));
process.nextTick(() => log.push('next-tick'));
log.push('sync-end');
}
module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');
test('execution order: sync -> nextTick -> promise -> timeout', (done) => {
const log = [];
recordOrder(log);
// sync code runs immediately
expect(log).toEqual(['sync-start', 'sync-end']);
setImmediate(() => {
expect(log).toEqual([
'sync-start',
'sync-end',
'next-tick',
'promise',
'timeout',
]);
done();
});
});
This test uses Jest with setImmediate to wait until all microtasks and the timers phase finish, then asserts the final order of log. This is a precise way to verify your understanding of the event loop instead of just reading logs visually.
npx jest order.test.js
PASS ./order.test.js
✓ execution order: sync -> nextTick -> promise -> timeout (5 ms)
9. Common Mistakes
- Recursive abuse of
process.nextTick: causes I/O starvation, preventing the server from handling new requests. - Assuming
setTimeout(fn, 0)runs immediately: in reality it always waits at least one event loop tick, and after all microtasks. - Running CPU-bound code on the main thread: for example, parsing huge JSON, bcrypt encryption with high cost, synchronous image processing — all should be pushed to Worker Thread or separate processes.
- Assuming order between
setTimeoutandsetImmediateat the top level: unreliable, avoid writing logic that depends on it.
When to be careful: if your application has endpoints that handle large data (Excel export, statistical calculations, image resizing), regularly measure
monitorEventLoopDelayto detect issues early before they affect the entire server.
10. Conclusion
The Event Loop is why Node.js can handle large numbers of connections with low resources, but it comes with a constraint: all synchronous JavaScript code runs on a single thread. Understanding the 6 phases (timers, pending callbacks, poll, check, close) and the position of the microtask queue (process.nextTick, Promise) helps you predict the correct execution order and avoid hard-to-debug mistakes.
When you encounter CPU-bound tasks, don’t hesitate to use worker_threads or separate processes — the event loop is only powerful when you don’t starve it.