The Event Loop in Depth
The event loop is the single mechanism that lets Node.js — a runtime built on one JavaScript thread — handle thousands of concurrent connections without spawning thousands of threads. It is the scheduler that decides, moment to moment, whether your code runs right now, waits for a callback, or waits its turn behind other queued work. Understanding the event loop precisely, not just as “Node is async,” is what separates developers who write fast, correct servers from developers who accidentally block the loop and freeze an entire app for every connected user. This lesson walks through exactly how the loop is structured, in what order things really run, and the mistakes that trip up almost everyone the first time.
Overview: How the Event Loop Works
JavaScript itself is single-threaded: there is one call stack, and only one line of your code executes at any instant. Node.js adds concurrency around that single thread using libuv, a C library that provides the event loop, a small thread pool, and cross-platform access to the operating system’s asynchronous I/O facilities (epoll on Linux, kqueue on macOS, IOCP on Windows). When you call an async API — reading a file, querying a socket, waiting on a timer — Node hands the request to libuv and your JavaScript keeps running. When the operating system (or a background thread) finishes the work, libuv queues the associated callback to run later, on the same single JavaScript thread, at a well-defined point.
Not all async work is equal. True network I/O (TCP sockets, most of http) is handled by the OS’s native async mechanisms and costs no extra thread. But some operations have no async OS API, so libuv runs them on a small internal thread pool (4 threads by default, resizable with the UV_THREADPOOL_SIZE environment variable): most fs file operations, dns.lookup, and some crypto functions like pbkdf2 and scrypt. That distinction matters — four slow file reads at once can genuinely queue behind each other waiting for a free thread pool slot, even though your JavaScript thread is completely free.
On top of the phase-based loop sit two microtask queues that Node drains completely before moving on: the process.nextTick queue (Node-specific, highest priority) and the promise microtask queue (used by resolved/rejected promises and queueMicrotask). After every single callback anywhere in the loop — not just once per phase, but after each individual callback — Node drains nextTick first, then promise microtasks, including any new ones those callbacks schedule, before continuing. This is why recursively scheduling process.nextTick calls can starve the entire loop and stop timers or I/O from ever running.
The Event Loop Phases
Once the initial synchronous script finishes and the microtask queues are empty, Node enters the phase-based loop. Each iteration (a “tick” of the loop) visits these phases in order, and the two microtask queues are drained again between every phase and every callback within a phase:
| Phase | What runs here |
|---|---|
| Timers | Callbacks from setTimeout and setInterval whose delay has elapsed |
| Pending callbacks | Certain system-level callbacks deferred from the previous cycle (e.g. some TCP errors) |
| Poll | Retrieves new I/O events and runs their callbacks; if nothing else is scheduled, the loop can block here waiting for I/O |
| Check | Callbacks scheduled with setImmediate |
| Close callbacks | close event handlers, e.g. socket.on("close", ...) |
If there is truly nothing left — no pending timers, no open handles, no I/O in flight — the process exits naturally. This is why a bare Node script that only does synchronous work exits immediately, while an HTTP server (which keeps a listening handle open) keeps running until you kill it.
Examples
Example 1: Synchronous code, nextTick, promises, and timers
console.log("A: synchronous");
setTimeout(() => {
console.log("D: setTimeout callback");
}, 0);
Promise.resolve().then(() => {
console.log("C: promise microtask");
});
process.nextTick(() => {
console.log("B: process.nextTick");
});
console.log("A2: synchronous (end)");
Output:
A: synchronous
A2: synchronous (end)
B: process.nextTick
C: promise microtask
D: setTimeout callback
All synchronous code (A and A2) runs first, in order, before anything else — the loop never interrupts running code. Once the call stack is empty, Node drains the nextTick queue completely (B), then the promise microtask queue (C), and only after both are empty does it enter the timers phase to run the setTimeout callback (D), even though its delay was 0.
Example 2: setImmediate vs setTimeout inside an I/O callback
import { readFile } from "node:fs";
import { fileURLToPath } from "node:url";
const thisFile = fileURLToPath(import.meta.url);
readFile(thisFile, () => {
setTimeout(() => {
console.log("setTimeout callback (timers phase)");
}, 0);
setImmediate(() => {
console.log("setImmediate callback (check phase)");
});
});
Output:
setImmediate callback (check phase)
setTimeout callback (timers phase)
This uses the callback-style fs.readFile deliberately, because it demonstrates a guarantee the promise-based API would hide: once the loop is already inside the poll phase servicing an I/O callback, moving on to check (setImmediate) is always faster than looping all the way back around to timers. So inside any I/O callback, setImmediate is guaranteed to fire before a setTimeout(fn, 0) scheduled at the same moment. At the very top level of a script, outside any I/O callback, that ordering is not guaranteed and can vary between runs.
Example 3: A blocking route on a real HTTP server
import http from "node:http";
function blockFor(ms) {
const start = Date.now();
while (Date.now() - start < ms) {
// busy loop: nothing else can run on this thread while this executes
}
}
const server = http.createServer((req, res) => {
if (req.url === "/blocking") {
blockFor(3000);
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Blocking route finished after 3s\n");
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Fast route finished immediately\n");
});
const PORT = process.env.PORT ?? 3000;
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
Output:
Server listening on port 3000
Start this server and request /blocking, then immediately request /fast from a second terminal (or browser tab). The /fast request will not respond until the full 3-second busy loop finishes, even though it does no work of its own — because blockFor occupies the single JavaScript thread synchronously, the event loop cannot reach the poll phase to accept or respond to any other connection until it returns. This is the single most important practical lesson about the event loop: one slow synchronous callback stalls every request your server is handling, not just the one that triggered it.
Under the Hood: Step by Step
- Node loads your entry module and executes all top-level synchronous code, start to finish, exactly like a normal script.
- Once the call stack is empty, Node drains the
process.nextTickqueue completely — including any newnextTickcalls scheduled by callbacks that were already in the queue. - Node then drains the promise microtask queue the same way, fully, before doing anything else.
- If there is no pending timer, I/O, or open handle left, the process exits here.
- Otherwise the loop enters the timers phase and runs any
setTimeout/setIntervalcallbacks whose delay has elapsed, draining the microtask queues after each one. - The loop proceeds through pending callbacks, then poll — where it executes I/O callbacks and, if nothing is scheduled anywhere else, can idle here waiting for new I/O rather than spinning.
- The loop moves to check to run any
setImmediatecallbacks, then close callbacks, and the whole cycle repeats for as long as there is work or open handles left.
Common Mistakes
Mistake 1: Using a synchronous call inside a request handler
import http from "node:http";
import { readFileSync } from "node:fs";
http.createServer((req, res) => {
const data = readFileSync("./report.csv", "utf8");
res.end(data);
}).listen(3000);
readFileSync blocks the entire event loop until the disk read finishes. On a busy server this means every other in-flight request — and every timer, and every incoming connection — waits behind this one file read. Use the promise-based API instead so the read happens off the main thread’s blocking path:
import http from "node:http";
import { readFile } from "node:fs/promises";
http.createServer(async (req, res) => {
try {
const data = await readFile("./report.csv", "utf8");
res.writeHead(200, { "Content-Type": "text/csv" });
res.end(data);
} catch (err) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Could not read report.csv");
}
}).listen(3000);
Mistake 2: Forgetting the ‘error’ event on an EventEmitter
import { EventEmitter } from "node:events";
const uploads = new EventEmitter();
uploads.emit("error", new Error("disk full"));
Node special-cases the "error" event: if an EventEmitter (including every stream) emits "error" and no listener is registered for it, Node throws the error and crashes the whole process. Always attach an error listener on any emitter or stream you create or consume:
import { EventEmitter } from "node:events";
const uploads = new EventEmitter();
uploads.on("error", (err) => {
console.error("Upload failed:", err.message);
});
uploads.emit("error", new Error("disk full"));
Output:
Upload failed: disk full
Mistake 3: Letting an async function’s rejection go unhandled
async function loadConfig() {
throw new Error("config.json is missing");
}
loadConfig();
Calling an async function without awaiting or handling its returned promise means a rejection has nowhere to go. Since Node 15, an unhandled promise rejection terminates the process by default instead of just logging a warning. Always attach a .catch() (or wrap the call site in try/await) for any promise you fire without awaiting:
async function loadConfig() {
throw new Error("config.json is missing");
}
loadConfig().catch((err) => {
console.error("Startup failed:", err.message);
});
Output:
Startup failed: config.json is missing
Best Practices
- Never use a
*Syncfsorcryptofunction inside a request handler; reserve them for one-off CLI or startup-time scripts where blocking briefly is harmless. - Break up long CPU-bound loops with
setImmediate, or move genuinely heavy computation toworker_threadsor a separate process, so the main thread can keep servicing I/O. - Always attach an
"error"listener to everyEventEmitterand stream you create or consume, especially ones you don’t fully control. - Prefer
async/awaitwithtry/catchover deeply nested callbacks, and always.catch()promises you don’t await directly. - Don’t rely on the relative ordering of a top-level
setTimeout(fn, 0)versussetImmediate— only the ordering inside an I/O callback is guaranteed. - Watch for excessive recursive
process.nextTickorqueueMicrotaskscheduling; it can starve timers and I/O indefinitely. - In production, monitor event loop lag (for example with
perf_hooks.monitorEventLoopDelay) so a blocking regression shows up in metrics before users complain.
Practice Exercises
- Write a script that mixes two
setTimeoutcalls, aprocess.nextTick, and a resolvedPromise, all scheduled from the top level. Predict the console output order on paper first, then run it and check your prediction against the phase and microtask rules from this lesson. - Build a small HTTP server with two routes:
/heavy, which runs a busy-wait loop for a few seconds, and/light, which responds immediately. Open two terminals and request/heavythen immediately/light; observe how long/lightactually takes to respond and explain why using what you learned about the poll phase. - Take a callback-based
fs.readFilecall with no error handling and rewrite it twice: once usingfs/promiseswithasync/awaitand atry/catch, and once keeping the callback style but adding a proper error-first check.
Summary
- Node runs your JavaScript on a single thread; concurrency comes from libuv handing off I/O to the OS or a small thread pool and queueing callbacks for later.
- The loop has distinct phases — timers, pending callbacks, poll, check, close callbacks — visited in that order every cycle.
process.nextTickand promise microtasks are drained completely after every single callback, before the loop can move on, and take priority over timers and I/O.- Inside an I/O callback,
setImmediateis guaranteed to run before a same-ticksetTimeout(fn, 0); at the top level, that ordering is not guaranteed. - Any synchronous, long-running call blocks the entire loop — and therefore every connected client — until it returns.
- Always handle the
"error"event on emitters and streams, and always handle rejected promises, or Node will crash the process.
