How the JavaScript Event Loop Works
At some point in your JavaScript journey, you’ve probably stared at console output and muttered, “Wait, why did that run after this?”
That moment—when you first bump into asynchronous behavior—is your introduction to the JavaScript Event Loop. And it’s a rite of passage.
I still remember the first time setTimeout(fn, 0)
ran after my Promise .then()
. It broke my brain. I spent the afternoon debugging a feature that wasn't broken, just misunderstood.
Let’s fix that for good.
JavaScript Is Single-Threaded—But Not Stupid
Despite running on a single thread, JavaScript somehow handles fetch()
, UI events, timers, and animations without choking.
So how does it pull off the illusion of multitasking?
Enter the Event Loop: a coordination system that manages what code runs, and when, based on stack state and task queues.
Step One: Understand the Call Stack
At the core, JavaScript uses a call stack to track function execution. It’s literally a stack:
function greet() {
console.log("Hello");
}
greet(); // Pushed → Runs → Popped
Synchronous tasks get stacked and unstacked in order. No surprises—yet.
Now Add Asynchronous Code to the Mix
console.log("Start");
setTimeout(() => {
console.log("Inside setTimeout");
}, 1000);
console.log("End");
You might expect:
Start
Inside setTimeout
End
But what you get is:
Start
End
Inside setTimeout
Why? Because setTimeout()
doesn't run inside the call stack. It hands off the callback to the browser’s Web API system. That’s where the Event Loop takes over.
How the Event Loop Actually Works
Here’s the simplified flow:
- 1Synchronous code runs in the Call Stack.
- 2Async APIs like
setTimeout
delegate the callback to Web APIs. - 3When ready, the callback is queued into the Task Queue.
- 4The Event Loop checks: Is the stack empty?
* If yes → It pushes the next task from the queue.
This explains why the timeout runs after all synchronous code is done. It’s not late—it’s just waiting in line.
Microtasks vs. Macrotasks: Yes, There's a Hierarchy
JavaScript has two task queues:
- Macrotasks:
setTimeout
,setInterval
,setImmediate
- Microtasks:
Promise.then
,queueMicrotask
,MutationObserver
And microtasks always run before the next macrotask.
console.log("Start");
setTimeout(() => {
console.log("Macrotask");
}, 0);
Promise.resolve().then(() => {
console.log("Microtask");
});
console.log("End");
Output:
Start
End
Microtask
Macrotask
If you're building responsive interfaces or using Promises in DOM code, knowing this order can save you from mysterious bugs.
What About async/await?
async/await
is just syntactic sugar for Promises. It feels synchronous but behaves like this:
async function fetchData() {
console.log("Fetching...");
const result = await new Promise(res => setTimeout(() => res("Done"), 1000));
console.log(result);
}
Behind the scenes:
new Promise(res => setTimeout(() => res("Done"), 1000))
.then(result => console.log(result));
When you await
, you pause your function. The rest of it goes into the microtask queue, waiting until the Promise resolves.
Visualizing the Flow
Here's a mental model I wish I had years ago:
→ Web APIs (timers, DOM events, fetch)
↓
→ Task Queue (macrotasks) → Microtask Queue (Promises)
↓ ↓
→ Event Loop checks... → If Call Stack is empty → executes next task
The Event Loop runs constantly, shuffling in tasks as the stack clears.
Real-World Pitfalls to Watch For
Blocking the Event Loop
function sleep(ms) {
const start = Date.now();
while (Date.now() - start < ms) {}
}
console.log("Start");
sleep(2000); // Completely blocks!
console.log("End");
This will freeze everything—animations, input, timers. Avoid long synchronous loops like this. If you need to pause without blocking, use setTimeout
or async functions.
Misusing setTimeout(fn, 0)
It doesn’t run immediately. It waits until the call stack is clear and microtasks are done.
If you truly want to run something right after the current tick, use:
Promise.resolve().then(() => {
// This runs before setTimeout
});
Or use queueMicrotask()
for even more precision.
Why This Matters in Real Projects
If you’ve ever built a UI with event handlers, debounce logic, or chained Promises, you’ve dealt with the Event Loop—even if you didn’t know it.
Frameworks like React and Vue rely on microtask timing to batch DOM updates. Async rendering in Node.js also dances around the loop. Understanding these timings helps you:
- Prevent race conditions
- Avoid janky UI updates
- Write faster, cleaner asynchronous logic
And if you’re exploring JS-based AI tools or real-time web apps, it's absolutely critical.
Want to Learn More?
Here are a few battle-tested resources I still revisit:
- Philip Roberts: “What the heck is the event loop anyway?” (JSConf)
- MDN Docs: Event Loop & Concurrency Model
- Loupe: Visualize the JS runtime live
TL;DR
- JavaScript is single-threaded but async thanks to the Event Loop.
- It coordinates the Call Stack, Web APIs, and task queues.
- Microtasks (Promises) run before macrotasks (setTimeout).
async/await
is just sugar over Promises and the microtask queue.- Don’t block the stack. Ever.
Once you understand the Event Loop, the weird becomes expected—and JavaScript becomes a lot less magical, and a lot more logical.