DEV Community

Cover image for The Node.js Event Loop Explained
Pratham
Pratham

Posted on

The Node.js Event Loop Explained

The invisible task manager that makes single-threaded JavaScript handle thousands of connections.


Here's a question that bugged me for weeks: how does Node.js handle thousands of concurrent requests with just one thread?

If JavaScript can only do one thing at a time (single-threaded), and a server needs to handle hundreds of requests simultaneously, and each request might involve a database query that takes 50 milliseconds... shouldn't everything grind to a halt?

It doesn't. And the reason is the event loop — a continuously running mechanism that orchestrates when things happen in Node.js. It's the traffic controller that decides what runs now, what waits, and what gets picked up next.

Understanding the event loop was the biggest "aha moment" of my Node.js journey in the ChaiCode Web Dev Cohort 2026. Let me give you the same clarity.


The Single-Thread Limitation

Let's start with the problem. JavaScript — whether in the browser or Node.js — runs on a single thread. That means one call stack, one set of instructions being processed at a time.

console.log("Task 1");
console.log("Task 2");
console.log("Task 3");
Enter fullscreen mode Exit fullscreen mode

This runs sequentially: Task 1 → Task 2 → Task 3. No issue here.

But what about this?

console.log("Accept request");
// Query database (takes 100ms)
// Read a file (takes 50ms)
// Call an external API (takes 200ms)
console.log("Send response");
Enter fullscreen mode Exit fullscreen mode

If all of this runs on one thread synchronously, the server is frozen for 350 milliseconds. It can't accept new requests, can't respond to anyone, can't do anything. One request at a time. For a web server, that's unacceptable.

The Question

How do you make a single thread handle thousands of concurrent I/O operations without blocking?

The Answer

You don't make it do everything. You make it manage everything. That's the event loop.


What Is the Event Loop?

The event loop is a mechanism that continuously monitors two things:

  1. The call stack — is there JavaScript code currently executing?
  2. The task queue — are there completed async callbacks waiting to run?

Its job is simple: when the call stack is empty, take the next callback from the queue and put it on the stack.

Think of the event loop as a task manager in an office:

The Task Manager (Event Loop):

  "Is the developer (call stack) busy with something right now?"
    → YES: "Let them finish. Don't interrupt."
    → NO:  "Great. Here's the next task from the inbox (queue)."

  The task manager NEVER does the work itself.
  It just decides WHAT gets worked on NEXT.
Enter fullscreen mode Exit fullscreen mode

The Call Stack — What's Running Right Now

The call stack is where JavaScript executes code. Functions go on the stack when they're called and come off when they return.

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

function processUser() {
  const message = greet("Pratham");
  console.log(message);
}

processUser();
Enter fullscreen mode Exit fullscreen mode
Call Stack (step by step):

  1. [processUser]              ← called, pushed onto stack
  2. [greet, processUser]       ← greet called inside processUser
  3. [processUser]              ← greet returned, popped off
  4. [console.log, processUser] ← console.log called
  5. [processUser]              ← console.log returned, popped off
  6. []                         ← processUser returned, stack empty
Enter fullscreen mode Exit fullscreen mode

The call stack handles synchronous code. It processes one function at a time, in order. When it's empty, the event loop checks the queue.


The Task Queue — What's Waiting to Run

When an async operation completes (timer fires, file is read, API responds), its callback doesn't jump directly onto the call stack. Instead, it goes into a task queue — a waiting line.

console.log("Start");

setTimeout(() => {
  console.log("Timer done!");
}, 1000);

console.log("End");
Enter fullscreen mode Exit fullscreen mode
Step 1: console.log("Start")
  Call Stack: [console.log]  → prints "Start"
  Queue: []

Step 2: setTimeout(callback, 1000)
  Call Stack: [setTimeout]   → registers timer, hands off to system
  Queue: []                  (timer is counting in the background)
  setTimeout pops off the stack immediately

Step 3: console.log("End")
  Call Stack: [console.log]  → prints "End"
  Queue: []

Step 4: (call stack is empty, 1 second passes, timer fires)
  Call Stack: []
  Queue: [timer callback]   ← callback added to queue

Step 5: Event loop moves callback from queue to stack
  Call Stack: [callback]     → prints "Timer done!"
  Queue: []
Enter fullscreen mode Exit fullscreen mode

Output: Start → End → Timer done!


Call Stack + Task Queue + Event Loop — The Complete Picture

Here's the diagram that ties it all together:

┌─────────────────────────────────────────────────────────┐
│                    NODE.JS                              │
│                                                         │
│  ┌─────────────────┐    ┌────────────────────────┐      │
│  │   CALL STACK    │    │     TASK QUEUE         │      │
│  │                 │    │                        │      │
│  │  (JavaScript    │    │  [callback 1]          │      │
│  │   runs here,    │    │  [callback 2]          │      │
│  │   one at a time)│    │  [callback 3]          │      │
│  │                 │    │                        │      │
│  └────────┬────────┘    └───────────┬────────────┘      │
│           │                         │                   │
│           │    ┌───────────────┐    │                   │
│           └────│  EVENT LOOP   │────┘                   │
│                │               │                        │
│                │ "Stack empty? │                        │
│                │   Move next   │                        │
│                │   callback    │                        │
│                │   from queue  │                        │
│                │   to stack."  │                        │
│                └───────────────┘                        │
│                        │                                │
│           ┌────────────┴───────────┐                    │
│           │    SYSTEM / libuv      │                    │
│           │                        │                    │
│           │  Handles actual I/O:   │                    │
│           │  • File reads          │                    │
│           │  • Network requests    │                    │
│           │  • Timers              │                    │
│           │  • Database queries    │                    │
│           │                        │                    │
│           │  When done → puts      │                    │
│           │  callback in queue     │                    │
│           └────────────────────────┘                    │
│                                                         │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The Flow

  1. Your JavaScript code runs on the call stack
  2. When it encounters an async operation (file read, timer, API call), it delegates to the system (libuv)
  3. The call stack moves on to the next line — it doesn't wait
  4. When the system finishes the I/O operation, it puts the callback in the task queue
  5. The event loop checks: "Is the call stack empty?" If yes, it moves the next callback from the queue to the stack
  6. The callback runs on the call stack
  7. Repeat forever

How Async Operations Are Handled — Traced Example

Let's trace a realistic scenario with multiple async operations:

console.log("1: Server starting");

setTimeout(() => {
  console.log("2: Timer callback");
}, 0);

const fs = require("fs");
fs.readFile("data.txt", "utf8", (err, data) => {
  console.log("3: File read complete");
});

setImmediate(() => {
  console.log("4: Immediate callback");
});

console.log("5: Server ready");
Enter fullscreen mode Exit fullscreen mode

Likely output:

1: Server starting
5: Server ready
2: Timer callback
4: Immediate callback
3: File read complete
Enter fullscreen mode Exit fullscreen mode

Why This Order?

Phase 1 — Synchronous code runs first:
  ✅ "1: Server starting" — synchronous, runs immediately
  ✅ setTimeout registered — callback queued (timer queue)
  ✅ fs.readFile started — delegated to system (I/O)
  ✅ setImmediate registered — callback queued (check queue)
  ✅ "5: Server ready" — synchronous, runs immediately

Phase 2 — Call stack is empty, event loop takes over:
  ✅ Timer callback (0ms) runs — "2: Timer callback"
  ✅ Immediate callback runs — "4: Immediate callback"

Phase 3 — File read completes (I/O is slower):
  ✅ File callback runs — "3: File read complete"
Enter fullscreen mode Exit fullscreen mode

All synchronous code runs first. Then the event loop processes queued callbacks in priority order.


Timers vs I/O Callbacks — High Level

Node.js doesn't have just one queue. Internally, different types of callbacks have different priorities. At a high level:

Priority (simplified):

  1. Synchronous code        ← always runs first
  2. Microtasks              ← Promise.then(), process.nextTick()
  3. Timers                  ← setTimeout(), setInterval()
  4. I/O callbacks           ← file reads, network responses
  5. Check phase             ← setImmediate()
  6. Close callbacks         ← socket.on("close")
Enter fullscreen mode Exit fullscreen mode

Timer Callbacks

setTimeout and setInterval callbacks are processed in the timers phase. Even with a 0ms delay, they're not "immediate" — they wait for the call stack to empty and their turn in the loop.

setTimeout(() => console.log("Timer!"), 0);
Promise.resolve().then(() => console.log("Promise!"));
console.log("Sync!");

// Output: Sync! → Promise! → Timer!
// Promises (microtasks) run BEFORE timer callbacks
Enter fullscreen mode Exit fullscreen mode

I/O Callbacks

Callbacks from file reads, database queries, and network requests are processed in the I/O polling phase. They run after timers but are the bread and butter of a Node.js server.

const fs = require("fs");

setTimeout(() => console.log("Timer"), 0);

fs.readFile("package.json", "utf8", () => {
  console.log("File read");
});

console.log("Sync");

// Likely: Sync → Timer → File read
// (File I/O takes longer than a 0ms timer)
Enter fullscreen mode Exit fullscreen mode

The Key Takeaway

You don't need to memorize every phase. Just understand the principle: synchronous code first, microtasks next, then timers, then I/O. The event loop processes them in a prioritized cycle.


The Event Loop Execution Cycle

Here's the simplified cycle the event loop repeats continuously:

┌──────────────────────────────────────────────┐
│                                              │
│                    START                     │
│                      │                       │
│                      ↓                       │
│         ┌───────────────────────┐            │
│         │   Run synchronous     │            │
│         │   code (call stack)   │            │
│         └───────────┬───────────┘            │
│                     ↓                        │
│         ┌────────────────────────┐           │
│         │   Process microtasks   │           │
│         │   (Promise.then,       │           │
│         │    process.nextTick)   │           │
│         └───────────┬────────────┘           │
│                     ↓                        │
│         ┌────────────────────────┐           │
│         │   Process timer        │           │
│         │   callbacks            │           │
│         │   (setTimeout, etc.)   │           │
│         └───────────┬────────────┘           │
│                     ↓                        │
│         ┌────────────────────────┐           │
│         │   Process I/O          │           │
│         │   callbacks            │           │
│         │   (files, network)     │           │
│         └───────────┬────────────┘           │
│                     ↓                        │
│         ┌────────────────────────┐           │
│         │   Process check        │           │
│         │   callbacks            │           │
│         │   (setImmediate)       │           │
│         └───────────┬────────────┘           │
│                     ↓                        │
│         ┌────────────────────────┐           │
│         │   Any more work?       │           │
│         │   Pending callbacks?   │           │
│         │   Active timers?       │           │
│         └───────┬────────┬───────┘           │
│            YES  │        │  NO               │
│                 ↓        ↓                   │
│           Loop again    Exit process         │
│                                              │
└──────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The event loop runs this cycle over and over. Each iteration is called a tick. A busy Node.js server processes thousands of ticks per second.


Role of the Event Loop in Scalability

The event loop is directly responsible for Node.js's ability to handle massive concurrency:

Without Event Loop (Traditional Threading)

1,000 concurrent requests:
  → 1,000 threads created
  → Each thread: ~2MB RAM
  → Total: ~2GB RAM just for threads
  → Most threads idle (waiting for I/O)
  → Thread switching overhead

  10,000 requests? You need 10,000 threads.
  RAM explodes. Performance degrades. Server struggles.
Enter fullscreen mode Exit fullscreen mode

With Event Loop (Node.js)

1,000 concurrent requests:
  → 1 thread + event loop
  → Each request: register callback, move on
  → RAM: minimal (callbacks are tiny)
  → No thread creation overhead
  → No thread switching overhead

  10,000 requests? Same 1 thread. Same event loop.
  RAM stays low. Callbacks process efficiently.
Enter fullscreen mode Exit fullscreen mode

The Queue Analogy

Think of a bank with two designs:

Traditional (multi-threaded):

┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Teller 1│ │ Teller 2│ │ Teller 3│ │ Teller 4│
│ (busy)  │ │ (idle)  │ │ (busy)  │ │ (idle)  │
└─────────┘ └─────────┘ └─────────┘ └─────────┘

4 tellers. Some are idle while waiting for the vault.
Customer 5 arrives? Might need to hire another teller.
Expensive. Wasteful during idle time.
Enter fullscreen mode Exit fullscreen mode

Node.js (event loop):

┌───────────────────────────────────┐
│         1 Super-Efficient Teller  │
│                                   │
│  "Take your form, I'll process    │
│   it when the vault responds.     │
│   NEXT customer, please!"         │
│                                   │
│  Queue: [C1 form] [C2 form] ...   │
└───────────────────────────────────┘

1 teller. Takes everyone's request immediately.
Processes results as they come back from the vault.
Handles 100 customers with the same single teller.
Enter fullscreen mode Exit fullscreen mode

Common Misconceptions

❌ "Node.js runs everything on one thread"

Not exactly. Your JavaScript code runs on one thread. But I/O operations (file reads, DNS lookups, compression) are handled by libuv's thread pool in the background. The event loop coordinates between your single JS thread and these background threads.

❌ "setTimeout(fn, 0) runs immediately"

No. It runs after all synchronous code and microtasks have finished. The 0 just means "as soon as possible, but not now."

❌ "The event loop makes Node.js asynchronous"

The event loop manages async operations. The actual async I/O is performed by the operating system and libuv. The event loop is the coordinator, not the worker.


Let's Practice: Hands-On Assignment

Part 1: Predict the Output

console.log("A");

setTimeout(() => console.log("B"), 0);

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

console.log("D");
Enter fullscreen mode Exit fullscreen mode

Answer: A → D → C → B

  • A, D — synchronous, run first
  • C — microtask (Promise), runs before timers
  • B — timer callback, runs last

Part 2: Trace the Event Loop

console.log("1");

setTimeout(() => {
  console.log("2");
  Promise.resolve().then(() => console.log("3"));
}, 0);

Promise.resolve().then(() => {
  console.log("4");
  setTimeout(() => console.log("5"), 0);
});

console.log("6");
Enter fullscreen mode Exit fullscreen mode

Answer: 1 → 6 → 4 → 2 → 3 → 5

Trace:

  • 1, 6 — synchronous
  • 4 — first microtask
  • 2 — first timer callback
  • 3 — microtask created inside timer callback
  • 5 — timer created inside microtask

Part 3: See the Event Loop in Action

const start = Date.now();

setTimeout(() => {
  console.log(`Timer: ${Date.now() - start}ms`);
}, 100);

// Simulate blocking the event loop for 200ms
const blockUntil = Date.now() + 200;
while (Date.now() < blockUntil) {}

console.log(`Sync done: ${Date.now() - start}ms`);
Enter fullscreen mode Exit fullscreen mode

Expected output:

Sync done: ~200ms
Timer: ~200ms   (NOT 100ms! — timer waited for blocking code to finish)
Enter fullscreen mode Exit fullscreen mode

The timer was ready at 100ms, but it couldn't run because the call stack was busy with the blocking while loop. The event loop could only deliver it after the stack cleared at 200ms.


Key Takeaways

  1. The event loop continuously checks: "Is the call stack empty? If yes, take the next callback from the queue." That's its entire job.
  2. Call stack = what's running now. Task queue = what's waiting to run. The event loop bridges them.
  3. Synchronous code always runs first. Then microtasks (Promises), then timers, then I/O callbacks. This order is consistent and predictable.
  4. The event loop enables scalability — one thread handles thousands of connections by never blocking on I/O, using callbacks instead of threads.
  5. Your JavaScript runs on one thread, but I/O operations happen in the background via libuv. The event loop coordinates between them.

Wrapping Up

The event loop is the most important concept in Node.js. Without it, single-threaded JavaScript couldn't be a server language. With it, Node.js handles concurrency better than many multi-threaded alternatives — using less memory, less overhead, and a simpler programming model.

You don't need to memorize every phase of the event loop to be productive. But understanding the core cycle — stack empty → check queue → run callback → repeat — gives you the ability to predict behavior, debug timing issues, and write efficient async code.

I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. The event loop was the concept that made everything about Node.js click — non-blocking I/O, async callbacks, why setTimeout(fn, 0) doesn't run immediately. Once you get it, you really get it.

Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.

Happy coding! 🚀


Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode

Top comments (0)