RezloRezloPrep
Sign Up Free
FundamentalsCore Concurrency Architecture

Event Loop

10 min readIntermediateUpdated for Node.js 20 LTS
The Node.js event loop allows Node.js to perform non-blocking I/O operations despite JavaScript running on a single thread by offloading operations to the system kernel whenever possible.

What is the Event Loop?

At its foundation, Node.js uses Google's V8 engine to parse and execute JavaScript synchronously on a single primary call stack. When a script runs, execution units are pushed onto the call stack and evaluated top-to-bottom. However, because network requests, disk reads, and system timers can incur significant latency, running them synchronously would freeze the entire server process.

To solve this without exposing multi-threaded user-space race conditions, Node.js embeds libuv — a high-performance C library that orchestrates asynchronous tasks with native OS asynchronous event demultiplexers (epoll on Linux, kqueue on macOS, and IOCP on Windows).

The event loop is essentially a persistent C++ while loop inside libuv that repeatedly sweeps through dedicated phases. Whenever a non-blocking asynchronous operation finishes, the operating system or worker thread signals libuv, and the corresponding callback is placed into the appropriate phase queue for dispatch back to the JavaScript V8 call stack.

Why does Node.js need it?

Traditional server architectures (such as Apache HTTP Server or early Java enterprise models) rely on allocating an isolated operating system thread for each incoming connection:

Traditional Multi-Threaded

Thread-per-Request

Each incoming client connection spawns or consumes an OS thread. Each idle thread reserves 1MB–2MB of stack memory. Context switching overhead scales steeply with concurrent connections.

Memory at 10k connections~10 GB - 20 GB
Node.js Reactor Pattern

Single Threaded Non-Blocking

One single event loop thread handles millions of sockets by registering socket handles directly with kernel demultiplexers. Threads are only dispatched for file I/O or crypto calculations.

Memory at 10k connections~50 MB - 120 MB

How it works (Execution Lifecycle)

Each rotation of the event loop is called a tick. Microtasks drain completely between every single callback.

Libuv Event Loop Phase TopologyLoop Cycle: Clockwise
V8 JAVASCRIPT CALL STACKExecutes synchronous frames first; triggers async APIs via bindingsPRIORITY INTERRUPT: MICROTASKS1. process.nextTick() > 2. Promises (.then/await)Checked immediately after call stack empties and between every phasePHASE 1: TIMERSsetTimeout, setIntervalExecutes elapsed min-heap timersPHASE 2: PENDING CALLBACKSSystem OperationsDeferred I/O errors (e.g. TCP ECONNREFUSED)PHASE 3: IDLE, PREPAREInternal Engine HousekeepingUsed internally by libuv for loop maintenancePHASE 4: POLL (CRITICAL)Retrieve new I/O eventsBlocks for incoming connections, file readsPHASE 5: CHECKsetImmediate() CallbacksExecutes right after the poll phase completesPHASE 6: CLOSE CALLBACKSsocket.on('close')Cleanup and disposal of connectionsOFFLOAD TARGETS (UNDERLYING LIBUV ABSTRACTIONS)OS Kernel Non-blocking: Epoll / Kqueue / IOCP (Network sockets, pipes, incoming HTTP)Libuv Threadpool: Default 4 worker threads (fs, crypto, zlib, dns.lookup)

Code Example: Execution Priority Order

The following script demonstrates the interplay between the synchronous call stack, microtask queues (process.nextTick and promises), and macrotask phases (setTimeout and setImmediate):

event_loop_puzzle.js
JavaScript
console.log('1: Synchronous start');

setTimeout(() => {
console.log('2: Timers phase callback (setTimeout 0ms)');
}, 0);

setImmediate(() => {
console.log('3: Check phase callback (setImmediate)');
});

Promise.resolve().then(() => {
console.log('4: Microtask queue (Promise.then)');
});

process.nextTick(() => {
console.log('5: Microtask queue (nextTick - highest priority)');
});

console.log('6: Synchronous end');
Console Output & Step-by-Step Reason
1: Synchronous start→ Evaluated instantly on the V8 Call Stack
6: Synchronous end→ Call stack continues synchronously to EOF
5: Microtask queue (nextTick - highest priority)→ nextTickQueue drains first before Promises
4: Microtask queue (Promise.then)→ other microtasks drain directly following nextTick
2: Timers phase callback (setTimeout 0ms)→ First phase of Event Loop (1ms minimum clamp expired)
3: Check phase callback (setImmediate)→ Phase 5: Check phase executes setImmediate

How to Explain it in an Interview

The 30-Second Spoken Pitch
"Node.js runs single-threaded JavaScript code on the V8 engine, but achieves non-blocking scalability by delegating heavy I/O and concurrency to libuv. Libuv delegates network events directly to the operating system's kernel notification system—like epoll or kqueue—and delegates blocking operations like disk I/O and cryptographic calculations to an internal background threadpool. When those tasks finish, their callbacks are queued and executed in dedicated event loop phases. In between every single callback, high-priority microtasks like promises and process.nextTick are drained immediately."
If the Interviewer Probes Deeper:
1. Microtask Starvation

Recursively scheduling process.nextTick() prevents the event loop from ever entering Phase 1 (Timers), starving all I/O callbacks completely.

2. UV_THREADPOOL_SIZE

By default, libuv allocates 4 background threads. In disk- or crypto-heavy workloads, increase this up to 128 via environment variable before boot.

3. CPU Blocking

Complex JSON parsing or regex evaluation locks the main thread, causing severe Event Loop Lag that drops HTTP throughput across all connected users.

What the Interviewer is Testing

Concurrency without Multi-threading

Confirming you understand why Node doesn't require explicit lock, mutex, or race-condition primitives in userland.

Microtasks vs Macrotasks

Knowing when Promise chains execute relative to setImmediate, setTimeout, and socket I/O events.

Libuv & Threadpool Offloading

Awareness of which operations stay on the main thread and which operations are dispatched to background threads.

Production Incident Diagnosis

Ability to pinpoint causes of Event Loop Lag metrics spiking in Datadog or Prometheus under load.

Common Mistake: Thinking setTimeout(fn, 0) Executes in 0ms

Candidates frequently state that setTimeout(fn, 0) executes instantly in 0 milliseconds. In reality, Node.js clamps all timeouts to a minimum of 1 millisecond (per HTML5 / V8 timer specifications). Furthermore, the timer callback will never execute until:

  1. The currently executing synchronous call stack completely clears.
  2. All pending microtasks in both nextTickQueue and promise microtask queues are exhausted.
  3. The event loop cycles back into Phase 1 (Timers) after the poll phase.

Follow-up Questions

Next TopicMultithreading in Node.js