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");
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");
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:
- The call stack — is there JavaScript code currently executing?
- 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.
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();
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
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");
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: []
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 │ │
│ └────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
The Flow
- Your JavaScript code runs on the call stack
- When it encounters an async operation (file read, timer, API call), it delegates to the system (libuv)
- The call stack moves on to the next line — it doesn't wait
- When the system finishes the I/O operation, it puts the callback in the task queue
- The event loop checks: "Is the call stack empty?" If yes, it moves the next callback from the queue to the stack
- The callback runs on the call stack
- 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");
Likely output:
1: Server starting
5: Server ready
2: Timer callback
4: Immediate callback
3: File read complete
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"
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")
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
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)
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 │
│ │
└──────────────────────────────────────────────┘
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.
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.
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.
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.
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");
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");
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`);
Expected output:
Sync done: ~200ms
Timer: ~200ms (NOT 100ms! — timer waited for blocking code to finish)
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
- The event loop continuously checks: "Is the call stack empty? If yes, take the next callback from the queue." That's its entire job.
- Call stack = what's running now. Task queue = what's waiting to run. The event loop bridges them.
- Synchronous code always runs first. Then microtasks (Promises), then timers, then I/O callbacks. This order is consistent and predictable.
- The event loop enables scalability — one thread handles thousands of connections by never blocking on I/O, using callbacks instead of threads.
- 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)