Why the event loop matters
Node.js runs your JavaScript on a single main thread, yet a single server can handle thousands of simultaneous connections. That is possible because the event loop never waits. When an operation would block, Node hands it to the operating system or to a thread pool and continues with other work. When the result is ready, a callback is queued and the loop runs it.
Understanding the loop explains a lot of Node behaviour: why a synchronous file read freezes the whole server, why a promise logs before a zero-delay timer, and why CPU-heavy work needs a worker. It is the single most important mental model for writing fast Node.
The loop and its phases
The event loop is a cycle of phases. Each phase has a queue of callbacks, and the loop processes them before moving on.
- Timers — callbacks scheduled by
setTimeoutandsetIntervalthat are due. - Pending callbacks — deferred system callbacks, such as some TCP errors.
- Idle, prepare — internal bookkeeping.
- Poll — retrieves new I/O events and runs their callbacks. If nothing is ready, it may block here briefly.
- Check — runs
setImmediatecallbacks. - Close callbacks — handles events like socket closure.
The loop keeps cycling as long as there is work to do. When there are no timers, no pending I/O and no handles, the process exits.
Microtasks: nextTick and promises
Between phases, and after each callback, Node drains two high-priority queues:
process.nextTickcallbacks, which run first.- Promise callbacks, which run after nextTick.
Both are drained completely before the loop advances, which has two consequences. First, they run before timers and I/O callbacks scheduled earlier. Second, a recursive nextTick or an endless promise chain can starve the loop, preventing I/O from ever running.
// order.js
console.log("1: synchronous");
setTimeout(() => console.log("5: timers"), 0);
setImmediate(() => console.log("6: check"));
Promise.resolve().then(() => console.log("3: promise"));
process.nextTick(() => console.log("2: nextTick"));
The output is 1, 2, 3, then 5 and 6. The order of 5 and 6 can vary at the top level because it depends on how long it takes to reach the timers phase; inside an I/O callback, setImmediate always runs before setTimeout.
The libuv thread pool
Some operations cannot be done asynchronously by the OS in a portable way: file system access, DNS lookups and some cryptographic functions. libuv runs these on a thread pool, defaulting to four threads, configurable with UV_THREADPOOL_SIZE.
That is why fs.readFile is non-blocking for your code even though it is not event-driven underneath. It also means a burst of file operations can queue behind a pool of four. The async APIs keep the main thread free; the pool handles the waiting.
Blocking the event loop
Anything synchronous and slow on the main thread blocks everything: other requests, timers and I/O. Common culprits include:
readFileSyncand other synchronous file calls in request handlers.- Large
JSON.parseorJSON.stringifyon big payloads. - Synchronous cryptography, such as
pbkdf2Syncwith high iterations. - Tight loops, regex backtracking and heavy data transforms.
- Blocking libraries that do CPU work on the main thread.
Measure rather than guess: event loop delay and event loop utilization tell you how much time the loop spends blocked. A rising delay under load is the signal.
Moving work off the loop
When work is genuinely CPU-bound, move it:
- worker_threads for computation in the same process, with message passing.
- child processes for heavier or isolated jobs.
- A separate service for the heaviest workloads, scaled independently.
- Chunking: break large tasks into smaller pieces and yield between them.
For I/O, use the async APIs and streams so the loop stays free.
Best practices
- Use async APIs in request handlers; never block the loop.
- Yield to I/O with
setImmediaterather than recursivenextTick. - Move CPU-bound work to worker threads or child processes.
- Stream large data instead of buffering it all in memory.
- Tune
UV_THREADPOOL_SIZEonly after measuring. - Monitor event loop delay and utilization in production.
- Keep callback chains short so microtasks do not starve I/O.
Common mistakes
- Using
readFileSyncinside a hot path. - Recursive
process.nextTickthat starves the loop. - Assuming Node is fully single-threaded and ignoring the thread pool.
- Doing heavy computation inline instead of offloading it.
- Expecting
setTimeout(fn, 0)to run before I/O callbacks. - Ignoring event loop delay until latency problems appear.
Where to go next
The event loop is the heart of Node. Continue with Streams for chunked I/O and File System for async file access. Revisit Node.js Basics if the runtime is still new, and then measure event loop delay on a service you run.