The Event Loop: Why Your Node.js Code Doesn't Just Run Top to Bottom

When I first started writing Node.js, I ran into something that genuinely confused me for a couple of days. I wrote this:
console.log("1. start");
setTimeout(() => {
console.log("2. inside timeout");
}, 0);
console.log("3. end");
I expected it to print 1, then 2, then 3. The timeout was zero milliseconds. Zero. How could anything come before it?
Output:
1. start
3. end
2. inside timeout
This confused me, I even checked if I had written the numbers wrong. The thing is, JavaScript is not broken here. This is exactly how it's supposed to work, and once you understand why, a huge chunk of async confusion just disappears.
One thing at a time for One thread
JavaScript runs on a single thread. There's one worker, and it can only do one job at a time. There is no parallel execution. It moves to next after it completes the current task.
This creates an obvious problem. If your code makes a network request that takes 3 seconds, does the entire program freeze for 3 seconds? In a traditional blocking model, yes. Everything stops, waits, then continues. That's fine for scripts but terrible for a web server that has to handle hundreds of requests.
Node.js solves this without adding more threads. The event loop is how it does that.
Think of it like a restaurant with one chef. The chef can't cook two dishes at the same time but they're not just standing at the stove staring at the pasta either. While the pasta boils, they chop vegetables, prep the sauce, plate a different dish. When the timer goes off, they come back to the pasta. One person, many things in progress, nothing truly blocked.
The event loop is the system that makes this coordination possible.
What's actually in memory when your code runs
Before getting into how the event loop works, it helps to know what's happening structurally when JavaScript executes your code.
There are two places that matter: the call stack and the task queue.
The call stack is where your code runs. When you call a function, it gets pushed onto the stack. When it returns, it pops off. JavaScript executes whatever is at the top of the stack.
The task queue is a waiting area. When an async operation finishes, like a setTimeout triggering or a file read completing, the callback gets placed in this queue.
The event loop's job is one loop that repeated forever or can say it's a while true loop which check if the call stack is empty. If it is, take the first task from the queue and push it onto the stack. That's the whole mechanism.
This is why the timeout in the example above printed last even though it was zero milliseconds. The setTimeout callback didn't run immediately. It went into the queue. The event loop didn't touch the queue until console.log("3. end") finished and the stack became empty.
Walking through the code, step by step
Let's trace exactly what happens with this code:
console.log("1. start");
setTimeout(() => {
console.log("2. inside timeout");
}, 0);
console.log("3. end");
Step 1: console.log("1. start") gets pushed onto the call stack and runs. Prints 1. start. Pops off.
Step 2: setTimeout(...) gets pushed onto the call stack. Node hands the timer off to the browser/OS (this part happens outside the JS engine), and the callback is registered. setTimeout itself pops off immediately. The JS engine didn't wait at all.
Step 3: console.log("3. end") pushes onto the stack, runs, prints 3. end, pops off.
Step 4: The call stack is now empty. The event loop checks the task queue. The 0ms timer already fired, so the callback is sitting there waiting. The event loop pushes it onto the now-empty stack.
Step 5: console.log("2. inside timeout") runs. Prints 2. inside timeout.
Timeline:
[start] stack: [log "1"] -> prints "1. start"
stack: [setTimeout] -> hands off to Node internals, pops immediately
stack: [log "3"] -> prints "3. end"
stack: [] -> EVENT LOOP sees queue has the cb
stack: [cb -> log "2"] -> prints "2. inside timeout"
[end]
The timeout didn't delay by 0 milliseconds. It delayed until the stack was clear. Zero is the minimum wait, not a guarantee of immediacy.
Why Node.js specifically needs this
Node was designed for servers. A server's entire job is handling requests, and requests involve waiting: reading from databases, fetching from APIs, reading files from disk. All of that is slow relative to CPU speed.
If Node ran synchronously, a single slow database query would freeze the server. Every other user would sit and wait. You'd need one thread per user, which is how older servers like Apache worked. That works until it doesn't, and it falls over somewhere in the hundreds or low thousands of concurrent connections because threads are expensive.
Node's event loop lets a single thread handle thousands of connections. While it's waiting on a database response for user A, it's processing user B's request. When the database responds for A, that callback goes in the queue and gets handled when the stack clears. No thread spawning, no context switching overhead.
This is why Node became popular for APIs and real-time apps because this model handles I/O-heavy workloads with very low resource usage.
Async operations: where do they actually go
When you call setTimeout, fs.readFile, fetch, or pretty much anything async, you're handing work to something outside the JS engine. Node.js has a layer called libuv that handles these operations using the operating system's async capabilities and a thread pool for things that aren't natively async (like some file operations).
Your JS Code
|
v
Node.js Runtime
|
----> V8 Engine (executes JS synchronously)
|
----> libuv (handles async I/O, timers, etc.)
|
----> OS networking (epoll/kqueue/IOCP)
----> Thread pool (file I/O, crypto, etc.)
When an async operation completes in libuv, the callback gets handed to the event loop, which puts it in the appropriate queue. Then the event loop picks it up when the call stack is empty and runs it.
This is why you can write code like this and it works fine:
const fs = require('fs');
fs.readFile('bigfile.txt', 'utf8', (err, data) => {
console.log('file done');
});
console.log('this runs while file is being read');
Output:
this runs while file is being read
file done
The readFile call dispatched the work and returned immediately. Your next line ran. Somewhere later, the file read finished, the callback arrived in the queue, and the event loop ran it once the stack was free.
Timers vs I/O callbacks
Timers (setTimeout, setInterval) and I/O callbacks don't go into the same queue. The event loop has multiple phases, and it visits them in order. Timers fire in one phase, I/O callbacks fire in another, and there are special microtask queues (for Promises) that run between phases.
The simplified picture:
Event Loop Phases (simplified):
[timers] -> setTimeout, setInterval callbacks
|
[I/O callbacks] -> file reads, network, etc.
|
[poll] -> wait for new I/O if nothing pending
|
[check] -> setImmediate callbacks
|
[close] -> cleanup callbacks
|
loop back to [timers]
Between EVERY phase:
[microtask queue] -> Promise .then(), queueMicrotask()
(runs to completion before next phase starts)
The microtask queue (Promises) is the sneaky one. It runs to completion before the event loop moves to the next phase. So Promise callbacks have higher priority than setTimeout callbacks.
This is why:
console.log("start");
setTimeout(() => console.log("setTimeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
Output:
start
end
promise
setTimeout
The Promise resolved synchronously but its .then() callback went into the microtask queue. The setTimeout callback went into the timer queue. After console.log("end") cleared the stack, the event loop drained the microtask queue first, then moved to timers.
What "non-blocking" actually means in practice
You'll see "non-blocking I/O" used to describe Node everywhere. When you do I/O (reading a file, making a network call), Node doesn't block the call stack waiting for it. It hands the work off and continues. The callback runs later.
The implication is that your JavaScript code is always running, never waiting. If something seems to be blocking, it's almost always CPU-heavy synchronous JavaScript rather than I/O. A function that does massive string manipulation or a tight loop will actually block Node because that work stays on the call stack.
// this blocks Node completely while running
function blockForMs(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {}
}
setTimeout(() => console.log("timer"), 100);
blockForMs(3000); // blocks for 3 seconds
// the timer callback fires at 3000ms+, not 100ms
The timer was set for 100ms but the while loop held the call stack hostage for 3 seconds. The event loop couldn't check the queue. This is called "blocking the event loop" and it's the thing Node developers actively try to avoid.
For CPU-heavy work, you'd use worker threads or break the work into smaller chunks. But for I/O, which is most of what servers do, the event loop handles it cleanly.
A slightly more realistic example
Here's something closer to actual server code:
const fs = require('fs');
console.log("server starting...");
// imagine these are three incoming requests hitting at the same time
fs.readFile('user1.json', 'utf8', (err, data) => {
console.log('user1 data ready');
});
fs.readFile('user2.json', 'utf8', (err, data) => {
console.log('user2 data ready');
});
setTimeout(() => {
console.log('cleanup timer fired');
}, 50);
console.log("all requests dispatched, waiting...");
Output (order of user1/user2 depends on which file read finishes first):
server starting...
all requests dispatched, waiting...
user1 data ready
user2 data ready
cleanup timer fired
Three async operations dispatched in rapid succession. None of them blocked the others. The last console.log ran before any of them returned because the stack clears synchronous code first. Then the event loop started processing callbacks as they arrived.
Scalability: why this matters for real apps
The event loop model is what lets Node handle high concurrency without throwing more hardware at the problem. A traditional server doing 1000 concurrent requests would have 1000 threads, each consuming memory and context switching time. Node handles those same 1000 requests in a single thread, with 1000 callbacks sitting in queues, processed one at a time as responses come in.
There are tradeoffs. CPU-intensive tasks hurt Node because they block the loop. Long synchronous operations hurt because they starve everything else in the queue. The model rewards I/O-heavy code that spends most of its time waiting, which is exactly what most APIs do.
Real apps at scale also use Node's cluster module or run multiple Node processes behind a load balancer to take advantage of multiple CPU cores. The event loop being single-threaded doesn't mean you're limited to one core. It means each Node process is single-threaded.
The call stack runs your synchronous code. One thing at a time, top to bottom. When you call an async function, the actual work is handed off outside the JS engine, and a callback is registered. When that work completes, the callback goes into a queue. The event loop watches the call stack, and when it's empty, it pulls from the queue and runs the next callback.
Microtasks (Promises) run before timers. Timers run before I/O callbacks in some phases. But for day-to-day code, the thing that matters is: synchronous code runs first, then queued callbacks, and Promises resolve before setTimeout.
You don't need to memorise the phase order to write good async code. You need to understand that the event loop is a queue manager, that your synchronous code always runs before any callbacks, and that blocking the call stack with heavy synchronous work kills your app's responsiveness.
References
Node.js docs: The Node.js Event Loop : the official explanation, worth reading once you're comfortable with the basics
Philip Roberts: What the heck is the event loop anyway? : the talk that explained this to JS developers, still the best visual walkthrough
Jake Archibald: Tasks, microtasks, queues and schedules : the definitive deep dive on microtasks vs task queues
libuv documentation : if you want to understand the layer below Node that actually does the async I/O






