Skip to main content

The Node.js Event Loop Explained

Updated 14 min readMarkdown

Every time someone describes Node.js as "non-blocking" or "asynchronous," they are describing behavior that comes from one mechanism: the event loop. It is the piece of Node.js architecture that most directly determines how your application behaves under concurrent load, and it is the thing that makes single-threaded JavaScript capable of handling thousands of simultaneous I/O operations without grinding to a halt.

This article explains what the event loop is, why Node.js needs it, how it coordinates the different pieces of async execution, and what role it plays in making Node.js scalable.


The Problem: One Thread, Many Tasks

JavaScript in Node.js runs on a single thread. That is not an accident or a limitation that was never fixed — it is a deliberate design choice. A single thread means no shared mutable state between concurrent operations, no race conditions from simultaneous writes, no lock contention, and no overhead from OS thread management.

But a single thread creates an obvious problem: if the thread is busy doing one thing, how does anything else happen? How can a server receive a new HTTP request while it is waiting for a database query from a previous one to return? How can a timer fire while the thread is processing a file read?

The answer is that most of what a server waits for is not computation. It is I/O — waiting for the OS to read from disk, for a network packet to arrive, for a database engine running on another machine to execute a query. During all of that waiting, the CPU is idle. The thread does not need to be occupied.

The event loop is the mechanism that takes advantage of that idle time. Instead of blocking the thread while waiting, Node.js dispatches I/O operations to the operating system, keeps track of what is pending, and uses the event loop to check for completions and run the appropriate callbacks. The thread stays free to handle other work.


What the Event Loop Actually Is

The event loop is not a data structure. It is a continuous execution cycle — a loop that Node.js runs for the entire lifetime of a process. On each iteration, it checks whether there are any pending callbacks to run, runs them, then loops again. If there is nothing to do and no pending I/O or timers, the process exits.

Think of it as a task manager that works from a queue. Tasks arrive — a file read completed, a timer fired, an HTTP request came in — and the event loop picks them up one by one and runs them on the main thread. It does not run multiple tasks simultaneously. It runs them sequentially, one after another, as fast as possible.

This sounds slow. In practice it is very fast, for two reasons. First, each individual task is usually small — receive a result, do a small transformation, send a response. Second, the waiting is done by the OS, not by the thread. The thread only runs when there is something useful to do.


The Call Stack

To understand the event loop, you need to understand what it is coordinating with.

The call stack is where JavaScript execution happens. Every time a function is called, a frame is pushed onto the stack. When the function returns, that frame is popped off. The call stack represents what is currently executing — the active chain of function calls at any given moment.

function greet(name) {
  return `Hello, ${name}`;
}

function respond() {
  return greet('Alice');
}

respond();

When respond() is called, the stack looks like this:

┌─────────────────┐
│  greet('Alice') │  ← currently executing
├─────────────────┤
│    respond()    │
├─────────────────┤
│  (main script)  │
└─────────────────┘

greet returns, its frame pops off. respond returns, its frame pops off. The stack is empty.

The call stack can only process one thing at a time. It is synchronous. If a function takes a long time to run, everything else waits.

The event loop's job is to feed the call stack — to take callbacks from the task queue and push them onto the stack when the stack is empty and there is work to do.


The Task Queue

The task queue (also called the callback queue or macrotask queue) is where callbacks wait to be run. When an async operation completes — a file read finishes, a timer fires, a network response arrives — its callback does not run immediately. It is placed in the task queue.

The event loop checks: is the call stack empty? If yes, and there is a callback in the task queue, move it to the call stack and run it. If the stack is not empty, wait.

┌─────────────────────────────────────────────────────────────────┐
│                         Node.js Runtime                         │
│                                                                 │
│   ┌──────────────────┐          ┌──────────────────────────┐    │
│   │    Call Stack    │          │       Task Queue         │    │
│   │                  │          │                          │    │
│   │  [active frame]  │          │  [fs callback]           │    │
│   │  [active frame]  │          │  [timer callback]        │    │
│   │  [active frame]  │          │  [http callback]         │    │
│   └────────┬─────────┘          └────────────┬─────────────┘    │
│            │                                 │                  │
│            │          ┌──────────┐           │                  │
│            └──────────│  Event   │───────────┘                  │
│                       │   Loop   │                              │
│                       │          │  When stack is empty:        │
│                       │          │  move next task to stack     │
│                       └──────────┘                              │
│                                                                 │
│   ┌─────────────────────────────────────────────────────────┐   │
│   │         Background Operations (via libuv / OS)          │   │
│   │   file reads · network requests · timers · DB calls     │   │
│   │         Complete → callback placed in task queue        │   │
│   └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

This is the core coordination: async operations run in the background (managed by libuv and the OS), their callbacks accumulate in the task queue, and the event loop drains that queue onto the call stack one task at a time.


How an Async Operation Moves Through the System

Tracing a single async operation from call to completion makes the flow concrete.

const fs = require('fs');

console.log('A');

fs.readFile('data.txt', 'utf8', (err, data) => {
  console.log('C - file is ready');
});

console.log('B');

Step by step:

1. Call stack executes console.log('A')         → prints "A"

2. Call stack hits fs.readFile(...)
   → Node.js hands the file read to libuv/OS
   → The callback is registered
   → fs.readFile returns immediately (non-blocking)
   → Call stack moves on

3. Call stack executes console.log('B')         → prints "B"

4. Main script finishes. Call stack is empty.
   Event loop waits.

5. OS signals: file read complete.
   Callback is placed in the task queue.

6. Event loop sees: call stack empty, task queue has an item.
   Moves callback to call stack.

7. Callback runs: console.log('C - file is ready') → prints "C - file is ready"

Output:

A
B
C - file is ready

The file read happened entirely outside the JavaScript thread. The callback only ran after everything synchronous had finished and the call stack was free.


The Microtask Queue

There is a second queue with higher priority than the task queue: the microtask queue. Promises use it.

When a Promise resolves, its .then() callback does not go to the task queue. It goes to the microtask queue. The event loop processes all microtasks before moving to the next task from the task queue. This means resolved Promises always run before the next timer or I/O callback.

console.log('A');

setTimeout(() => console.log('D - setTimeout'), 0);

Promise.resolve().then(() => console.log('C - Promise'));

console.log('B');

Output:

A
B
C - Promise
D - setTimeout

Even though the setTimeout delay is 0ms, the Promise callback runs first. The microtask queue is always drained completely between each task from the task queue.

┌────────────────────────────────────────────────────────────────┐
│                   Execution Priority Order                     │
│                                                                │
│  1. Call stack (synchronous code runs first, always)           │
│     └── console.log('A'), console.log('B'), ...               │
│                                                                │
│  2. Microtask queue (after each task, drain completely)        │
│     └── Resolved Promises, process.nextTick callbacks          │
│                                                                │
│  3. Task queue (macrotasks — one per event loop iteration)     │
│     └── setTimeout, setInterval, I/O callbacks,               │
│         setImmediate                                           │
└────────────────────────────────────────────────────────────────┘

Understanding this order prevents subtle bugs. If you queue many Promises that resolve synchronously, they all run before any I/O callbacks or timers, which can starve the task queue in pathological cases.


Timers vs I/O Callbacks

Two of the most common sources of callbacks are timers and I/O operations. They behave slightly differently.

Timers (setTimeout, setInterval) schedule callbacks to run after a minimum delay. The delay is a lower bound, not a guarantee — if the call stack is busy or if many tasks are queued, a timer callback may run later than its specified delay. setTimeout(fn, 0) does not mean "run immediately." It means "run as soon as the call stack is clear and the task queue reaches this callback."

console.log('start');

setTimeout(() => console.log('timer: 100ms'), 100);
setTimeout(() => console.log('timer: 0ms'), 0);

// Simulate synchronous work taking 200ms
const start = Date.now();
while (Date.now() - start < 200) {} // blocks for 200ms

console.log('end');
// Both timers run after this, even the 100ms one

Output:

start
end
timer: 0ms
timer: 100ms

Both timers were delayed past their specified duration because the synchronous block held the call stack. This illustrates the core principle: blocking the call stack blocks everything.

I/O callbacks are placed in the task queue when the OS signals completion of a file read, network operation, or other I/O. They have no scheduled delay — they run as soon as the operation completes and the event loop gets to them. In practice, I/O callbacks tend to run in the next iteration of the event loop after the operation completes.

const fs = require('fs');

setTimeout(() => console.log('timer'), 0);

fs.readFile('small-file.txt', 'utf8', () => {
  console.log('file read');
});

console.log('synchronous');

The order of 'timer' vs 'file read' is not guaranteed — it depends on how quickly the OS completes the file read relative to when the timer phase runs. This is expected behavior. Do not write code that relies on a specific ordering between unrelated async operations unless you chain them explicitly.


The Event Loop Execution Cycle

Zooming out from individual operations, the event loop runs in a repeating cycle with distinct phases. The detail of all phases matters if you are debugging subtle timing issues, but the high-level picture is what drives most real behavior.

┌─────────────────────────────────────────────────────────────────────┐
│                    Event Loop Cycle (simplified)                    │
│                                                                     │
│                                                                     │
│   ┌─────────────┐                                                   │
│   │   START     │                                                   │
│   │  (process   │                                                   │
│   │   starts)   │                                                   │
│   └──────┬──────┘                                                   │
│          │                                                          │
│          ▼                                                          │
│   ┌─────────────────────────────────────────────────────────────┐   │
│   │  Run synchronous code (main script, requires, etc.)         │   │
│   └──────────────────────────────┬──────────────────────────────┘   │
│                                  │                                  │
│          ┌───────────────────────┘                                  │
│          │        ┌──────────────────────────────────────────┐      │
│          │        │                                          │      │
│          ▼        ▼                                          │      │
│   ┌──────────────────────┐                                   │      │
│   │  Drain microtask     │                                   │      │
│   │  queue (Promises,    │◄─── runs after every task        │      │
│   │  process.nextTick)   │                                   │      │
│   └──────────┬───────────┘                                   │      │
│              │                                               │      │
│              ▼                                               │      │
│   ┌──────────────────────┐                                   │      │
│   │  Timers phase        │                                   │      │
│   │  Run expired         │                                   │      │
│   │  setTimeout /        │                                   │      │
│   │  setInterval         │                                   │      │
│   └──────────┬───────────┘                                   │      │
│              │  (drain microtasks after each callback)        │      │
│              ▼                                               │      │
│   ┌──────────────────────┐                                   │      │
│   │  I/O callbacks phase │                                   │      │
│   │  Run completed       │                                   │      │
│   │  fs, net, http       │                                   │      │
│   │  callbacks           │                                   │      │
│   └──────────┬───────────┘                                   │      │
│              │  (drain microtasks after each callback)        │      │
│              ▼                                               │      │
│   ┌──────────────────────┐                                   │      │
│   │  Check phase         │                                   │      │
│   │  Run setImmediate    │                                   │      │
│   │  callbacks           │                                   │      │
│   └──────────┬───────────┘                                   │      │
│              │                                               │      │
│              ▼                                               │      │
│   ┌──────────────────────┐       ┌─────────────────────┐    │      │
│   │  Any pending I/O or  │  No   │  Exit process       │    │      │
│   │  timers remaining?   │──────►│                     │    │      │
│   └──────────┬───────────┘       └─────────────────────┘    │      │
│              │ Yes                                           │      │
│              └───────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────────────────┘

The key behaviors to internalize:

  • Synchronous code always runs first and completely before any callbacks

  • Microtasks (Promises) are drained after every individual task, not just at the end of a phase

  • Timers run when their delay has elapsed, subject to queue state

  • I/O callbacks run after their operations complete

  • The loop continues until no I/O is pending and no timers are scheduled


The Event Loop and Scalability

The event loop is the reason Node.js can serve many concurrent users without a thread per user.

In a thread-per-request model, 10,000 concurrent connections means 10,000 threads. At 1–8 MB per thread stack, that is significant memory overhead before a single byte of application data is considered. Scheduling 10,000 threads also has OS overhead — context switches are not free.

In Node.js, 10,000 concurrent connections means 10,000 pending I/O operations registered with the OS, a handful of active tasks in the task queue, and one thread processing those tasks as completions arrive. Memory usage scales with the data per connection, not with a per-thread overhead. There is no scheduler thrash from thousands of OS threads.

Thread-per-request at 10k concurrent connections:
──────────────────────────────────────────────────
10,000 threads × ~2MB stack = ~20GB memory overhead
OS context-switches between 10,000 threads continuously
Most threads idle (waiting on I/O), consuming resources anyway

Node.js at 10k concurrent connections:
──────────────────────────────────────────────────
1 thread
10,000 pending OS-level I/O registrations (cheap)
Event loop processes completions as they arrive
Memory: proportional to connection data, not thread count

This is why companies like LinkedIn and Netflix moved specific high-concurrency services to Node.js and saw server count reductions. The workload — many concurrent connections, each waiting on I/O — maps directly onto what the event loop is designed to do efficiently.

The scalability has a condition attached: the event loop must stay free. Long-running synchronous operations on the main thread block all of the 10,000 waiting connections simultaneously. A synchronous loop that takes 500ms to complete means every pending callback waits 500ms. Keeping tasks short, and offloading CPU-heavy work to Worker Threads or child processes, is the operational discipline that the event loop model requires.


Common Misconceptions

"async/await runs in parallel." It does not. await suspends the current async function and yields to the event loop, but JavaScript callbacks still run one at a time. await enables concurrency — multiple I/O operations in-flight — but not parallelism of JavaScript code.

"setTimeout(fn, 0) runs immediately." It does not. It schedules a callback to run in the timers phase of the next event loop iteration, after synchronous code and microtasks have cleared. Under load, the actual delay may be longer than 0ms.

"The event loop means Node.js can't get stuck." It can. Any synchronous operation that takes significant time — a large JSON.parse, a complex regex, a blocking loop — holds the call stack and blocks the entire event loop. Non-blocking I/O keeps the event loop free; CPU work does not.

"Promises are always async." A Promise executor runs synchronously. The callbacks in .then() are microtasks and run asynchronously, but the function you pass to new Promise(...) executes immediately on the call stack.


Summary

The event loop is the mechanism that allows a single-threaded runtime to handle concurrent I/O efficiently. It works by coordinating three things: the call stack where JavaScript executes synchronously, the task queue where completed async callbacks wait, and the background where the OS and libuv process I/O operations.

The cycle is continuous: run synchronous code, drain microtasks, process timers, process I/O callbacks, repeat. As long as the main thread is kept free from long synchronous work, this cycle can handle thousands of concurrent operations without per-thread memory overhead or context-switch cost.

The event loop is not magic. It is a well-understood mechanism with predictable behavior. Understanding it removes the mystery from why async code executes in the order it does, why blocking the main thread has system-wide consequences, and why Node.js is the architecture it is.


From here, the practical application of event loop knowledge is in understanding async patterns: when to use Promise.all vs sequential awaits, why process.nextTick has higher priority than resolved Promises in some Node.js versions, and how Worker Threads extend Node.js into CPU-bound workloads without blocking the event loop.

No comments yet. Be the first to comment.

More from this blog