The event loop is the mechanism that makes Node's whole concurrency story work: a loop, driven by libuv, that decides which piece of your pending JavaScript runs next. It's the thing that lets a single-threaded language handle thousands of concurrent connections - and the thing that turns one careless synchronous function into an outage.
How Node.js Works covers the broader V8-plus-libuv picture; this page goes one level deeper into the loop itself - its phases, its scheduling rules, and the assumptions that make it fast when respected and dangerous when ignored. Treat this as the conceptual anchor for the section: the hands-on examples in Event Loop Basics and the phase-by-phase and microtask deep dives that follow build on the model described here.
The event loop is a fixed cycle of phases that libuv runs repeatedly, each phase responsible for one category of pending callback, with the call stack required to be empty before the loop advances.
Insight: Almost every "why did this run before that?" or "why did my API stall?" question in Node traces back to how this cycle actually orders work - guessing gets you the wrong answer more often than not.
When to Use This Model: Diagnosing ordering bugs, reasoning about latency under load, deciding whether work belongs on the main thread, and reading the more detailed phase/microtask pages with the right frame already in place.
Limitations/Trade-offs: The loop guarantees relative ordering, not exact timing - and it's cooperative, meaning it trusts every callback to finish quickly. It has no way to preempt a callback that doesn't.
Related Topics: libuv phases, microtasks vs. macrotasks, timers and scheduling, event-loop blockage detection.
JavaScript in Node follows run-to-completion: once a function starts executing, it runs until it returns, and nothing else - no other callback, no I/O completion, nothing - can interrupt it. The event loop's entire job is deciding what runs next, and it only gets to make that decision when the call stack is completely empty.
console.log('a');setTimeout(() => console.log('b'), 0);console.log('c');// a, c, b - "b" cannot run until the synchronous code above it finishes
A useful mental model: picture a night-shift security guard making fixed rounds through a building. Each round visits the same stations in the same order - check the timers on the front door, clear the mailroom, listen at the monitors for new activity, sweep for anything flagged urgent, lock up anything closing - then the guard starts the round again from the top. The guard never does two stations at once, and never skips ahead; if something urgent happens between rounds, it waits for the guard to reach that station. That fixed, repeating circuit is the event loop - each "station" is a phase, and the guard is the single JavaScript thread executing whatever that phase hands it.
The loop isn't optional infrastructure sitting beside your code - it's what keeps the Node process alive at all. A process exits once there's nothing left to do: no pending timers, no open server sockets, no queued I/O. Every server.listen() or open handle is effectively a reason for the guard to keep making rounds; call .unref() on a handle to tell the loop "don't stay alive just for this."
Each pass through the loop visits the same phases in the same fixed order:
┌───────────────┐
│ timers │ setTimeout / setInterval callbacks whose time has passed
├───────────────┤
│ pending cbs │ some system-level callbacks deferred from the previous pass
├───────────────┤
│ idle, prepare │ internal use only
├───────────────┤
│ poll │ retrieve new I/O events; run I/O callbacks (most work happens here)
├───────────────┤
│ check │ setImmediate callbacks
├───────────────┤
│ close cbs │ e.g. socket.on('close', ...)
└───────────────┘
│
└──────────── back to timers, forever (while work remains)
That's the skeleton - libuv Phases covers what actually happens inside each one, including the poll phase's blocking behavior and how it decides when to move on. What matters at this level is the shape: it's a fixed sequence, not a single first-in-first-out queue. A callback's phase determines roughly when it can run, not the order you registered it in.
Microtasks - resolved Promises, queueMicrotask, process.nextTick - sit outside that phase cycle entirely. Node drains the microtask queue completely after every callback, not just once per full lap, so microtasks always get a chance to run before the loop moves from one phase (or one callback) to the next. That's why a Promise.resolve().then() reliably beats a setTimeout(fn, 0) even though both look like "run soon" - one is a macrotask waiting for its phase, the other cuts in immediately after the current callback finishes. Microtasks vs Macrotasks covers the exact ordering rules, including where process.nextTick fits.
The poll phase deserves special mention because it's where the loop spends most of its time in an I/O-bound app: when there's nothing else to do, libuv can let poll block, waiting on the OS for new I/O events, rather than spinning and burning CPU. That's the loop being efficient at idle - it's not busy-waiting, it's asleep until the OS wakes it up.
The event loop is one point on a spectrum of concurrency models, and knowing where it sits clarifies what it's good at and what it isn't:
Model
Strength
Weakness
Best Fit
Node's event loop (cooperative, single JS thread)
Cheap, high-concurrency I/O; simple mental model, no data races in JS
One long callback stalls everything sharing that thread
I/O-bound servers, proxies, real-time gateways
OS thread-per-request (preemptive)
The OS can interrupt a slow request; true parallelism
Memory/scheduling cost per idle thread; race conditions to manage
CPU-heavy or blocking-heavy workloads by default
Green threads / goroutines (e.g. Go)
Lightweight, preemptible units scheduled by the runtime
Different memory/GC model to reason about
High-concurrency CPU-and-I/O mixed workloads
Actor model (e.g. Erlang/BEAM)
True per-process isolation; a crashed actor doesn't stall others
Heavier message-passing overhead
Fault-tolerant, highly concurrent telecom-style systems
Node's choice - cooperative scheduling on one thread - is why the platform is unusually good at high-concurrency I/O and unusually bad at silently absorbing a CPU-bound mistake. There's no OS-level safety net; the loop trusts every callback to hand control back quickly. This is also why framework authors (Express, Fastify, NestJS) build routing and middleware around the assumption that handlers return control promptly - a blocking middleware doesn't just slow its own request, it delays the guard's entire round.
In production, that cooperative nature is exactly what you're watching for: rising event-loop delay or utilization (see Detecting Event-Loop Blockage) means some callback, somewhere, isn't cooperating. Event Loop Best Practices turns this model into concrete operational rules - what to keep off the main thread, and how to structure handlers so the loop stays free.
"The event loop is a single queue, and callbacks run in the order they were registered." It's a fixed sequence of phases, each with its own queue - a callback's type (timer, I/O, setImmediate) determines which phase handles it, not registration order alone.
"Promises and setTimeout are scheduled the same way, just with different delays." They're different categories entirely - Promises are microtasks that drain between every callback; setTimeout callbacks are macrotasks tied to a specific phase. They aren't comparable by "delay."
"The loop constantly polls in a busy-wait, burning CPU even when idle." The poll phase can block and let the OS wake it up when I/O is ready - an idle Node process isn't spinning a CPU core.
"If I use async/await, the event loop can't get stuck on my code."async changes how a result is delivered, not whether the synchronous parts of that function can occupy the call stack. A long loop inside an async function blocks exactly as much as it would outside one.
"The event loop guarantees my callback runs at a specific time." It guarantees relative ordering rules (microtasks before the next phase, timers phase before poll, etc.), never an exact timestamp - setTimeout(fn, 0) means "no earlier than," not "exactly at."
A repeating cycle of fixed phases, run by libuv, that decides which pending callback gets the single JavaScript thread next - and only advances once the current callback's call stack is empty.
Why does Node use one event loop instead of a thread per request?
Because most server work is spent waiting on I/O, not computing - a single thread that never blocks on I/O can serve far more concurrent connections than one thread per connection, most of which would sit idle waiting. See How Node.js Works for the full V8/libuv reasoning.
How does the event loop relate to the call stack?
The loop can only pick the next callback once the call stack is completely empty - JavaScript's run-to-completion rule means nothing preempts a running function, so the "loop" part is really just "what happens between one empty stack and the next."
Are microtasks part of the phase cycle?
No - microtasks (Promises, queueMicrotask, process.nextTick) drain completely after every callback, independent of which phase just ran. They get priority over moving to the next phase or the next macrotask.
Why does a `setTimeout(fn, 0)` run after a `Promise.resolve().then()`?
The Promise callback is a microtask and drains immediately after the currently running code finishes. The timer callback is a macrotask that has to wait for the loop to reach the timers phase on a later pass - microtasks always win that race.
What actually keeps a Node process alive?
Any pending timer, open handle (like a listening server or socket), or queued I/O operation. Once none of those remain, the loop has nothing left to cycle through and the process exits naturally - .unref() on a handle opts it out of counting toward "keep the process alive."
Does the event loop busy-wait when there's nothing to do?
No. The poll phase can block, letting the OS wake the process up when I/O is actually ready, instead of spinning and consuming CPU while idle.
Why does one slow request affect requests that have nothing to do with it?
They all share the same call stack. A synchronous, CPU-bound section of any callback occupies the single JS thread completely, so the loop cannot advance to any other pending callback - including ones for otherwise-unrelated requests - until it returns.
Is the browser's event loop the same as Node's?
They share the same core idea - run to completion, drain microtasks, then handle the next task - but the details differ. Browsers interleave rendering and other UI-specific tasks into their loop; Node's loop is built around libuv's phase model (timers, poll, check, etc.) with no rendering step at all.
Can the event loop be preempted, the way an OS preempts threads?
No - it's cooperative. Nothing forces a long-running callback to yield control back to the loop; it has to return on its own (or hand work off to worker_threads/the libuv thread pool) for anything else to run.
What's the difference between a "phase" and a "microtask queue"?
A phase is a stop in the loop's fixed circuit (timers, poll, check, …), each handling one category of macrotask. The microtask queue isn't a phase at all - it's drained in full after every single callback, phase-independent, which is why microtasks consistently run sooner than the next phase's work.
Do multiple Node processes share one event loop?
No - every Node process has its own independent event loop and its own single JS thread. Tools like the cluster module or a process manager run multiple separate processes, each with its own loop, to use more than one CPU core.