Node.js Event Loop: The DJ That Never Stops Playing
Node.js is single-threaded. That means it can only do one thing at a time. And yet, it handles thousands of simultaneous connections without breaking a sweat. How?
The answer is the Event Loop — and understanding it will change how you think about asynchronous code forever.
Let's walk through it using a DJ party analogy. Stick with me.
The DJ's Table (Call Stack)
Picture a DJ at a party. The DJ's table (the Call Stack) can only play one song at a time. That's it. No matter how many requests come in, the DJ processes them one by one, in order.
In JavaScript terms, this is the call stack — a LIFO (Last In, First Out) structure where functions are pushed on when called and popped off when they return.
function greetGuest(name) {
console.log(`Welcome, ${name}!`);
}
function openParty() {
greetGuest("Alice");
greetGuest("Bob");
}
openParty();
// Output:
// Welcome, Alice!
// Welcome, Bob!
Each function call gets stacked, executed, and removed before the next one runs. Simple, sequential, predictable.
The Roadies (Node Internals / libuv)
Now, the DJ can't leave the table to go set up the fog machine. That's the roadies' job.
In Node.js, the roadies are Node's internals (libuv and the OS async layer — equivalent to what browsers call Web APIs): setTimeout, fetch, file system reads, network requests. When you call one of these, Node hands the task off to the environment and immediately continues — the DJ keeps spinning tracks while the roadies do their thing.
console.log("Party starts");
setTimeout(() => {
console.log("Fog machine ready!");
}, 2000);
console.log("DJ is already playing");
// Output:
// Party starts
// DJ is already playing
// Fog machine ready! (2 seconds later)
Notice: Node didn't stop and wait 2 seconds. It delegated to the roadie and moved on. That's non-blocking I/O.
The Request Queue (Macrotask Queue)
When a roadie finishes the job, the result doesn't go straight to the DJ's table. It goes into the request queue — the Macrotask Queue.
The Event Loop (the DJ's discipline) has one rule: only pick from the queue when the table is completely free.
console.log("1 - DJ opens the set");
setTimeout(() => console.log("3 - First request from the queue"), 0);
setTimeout(() => console.log("4 - Second request from the queue"), 0);
console.log("2 - DJ finishes the opening");
// Output:
// 1 - DJ opens the set
// 2 - DJ finishes the opening
// 3 - First request from the queue
// 4 - Second request from the queue
Even with setTimeout(..., 0), the callbacks go through the queue and only run after the current call stack clears.
Common macrotasks: setTimeout, setInterval, I/O callbacks, setImmediate (Node.js).
The VIP Queue (Microtask Queue)
Here's where it gets interesting.
Some guests at the party are VIPs. They have a separate queue, and the DJ has a strict policy: after each song (macrotask), empty the entire VIP queue before touching the regular queue. In fact, the VIP queue also runs after the initial call stack clears — before the very first song from the regular queue plays.
The Microtask Queue works exactly like this. It's processed completely after every macrotask, before the next macrotask starts.
console.log("1 - Start");
setTimeout(() => console.log("5 - Macrotask (setTimeout)"), 0);
Promise.resolve()
.then(() => console.log("3 - Microtask 1 (Promise)"))
.then(() => console.log("4 - Microtask 2 (chained Promise)"));
console.log("2 - End of synchronous code");
// Output:
// 1 - Start
// 2 - End of synchronous code
// 3 - Microtask 1 (Promise)
// 4 - Microtask 2 (chained Promise)
// 5 - Macrotask (setTimeout)
The Promise callbacks (microtasks) always run before the setTimeout callback (macrotask), even though the setTimeout was registered first.
Common microtasks: Promise.then, Promise.catch, Promise.finally, queueMicrotask, async/await continuations.
The Full Set
Let's put it all together with one complete example:
console.log("1 - Sync: party starts");
setTimeout(() => console.log("6 - Macrotask: roadie's job is done"), 0);
Promise.resolve()
.then(() => {
console.log("4 - Microtask: VIP guest arrives");
})
.then(() => console.log("5 - Microtask: VIP gets their drink"));
setTimeout(() => console.log("7 - Macrotask: second roadie done"), 0);
console.log("2 - Sync: DJ keeps playing");
console.log("3 - Sync: call stack clears");
// Output:
// 1 - Sync: party starts
// 2 - Sync: DJ keeps playing
// 3 - Sync: call stack clears
// 4 - Microtask: VIP guest arrives
// 5 - Microtask: VIP gets their drink
// 6 - Macrotask: roadie's job is done
// 7 - Macrotask: second roadie done
Order of execution:
- All synchronous code (call stack drains completely)
- All microtasks (VIP queue — fully drained)
- One macrotask (regular queue)
- All microtasks again (VIP queue — fully drained)
- Next macrotask... and so on
3 Golden Rules
- One thing at a time. The call stack is single-threaded — no parallelism — it handles many tasks by delegating, not by doing them at the same time.
- Microtasks always come before macrotasks. After every macrotask, drain the entire microtask queue first.
- Async operations don't block. They're delegated to the environment (roadies), freeing the call stack to keep working.
Understanding these three rules explains 90% of the "weird" behavior you'll encounter with async JavaScript in Node.js.