The Event Loop: JavaScript's Secret Traffic Controller
2025-04-04
You’ve probably heard someone say “JavaScript is single-threaded” and nodded along while quietly wondering what that actually means and why anyone should care.
Fair. Let’s fix that today.
The event loop is the reason JavaScript can feel fast and responsive even though it only does one thing at a time. It’s the secret behind every setTimeout, every fetch(), every smooth animation. Once you get it, a huge chunk of async confusion just… dissolves.
Let’s walk through it together.
First: What Even Is “Single-Threaded”?
Imagine a restaurant with only one cook.
Every order that comes in has to wait for the cook to finish the current one. No parallel cooking. One task at a time, in sequence.
That’s JavaScript. One thread, one call stack, one thing happening at any given moment.
But here’s the twist — that one cook is incredibly organized. They have a system for handling tasks that take a long time (like waiting for water to boil) without standing there doing nothing. They hand off the slow stuff and come back to it later.
That system is the event loop.
The Main Players
To understand the event loop, you need to meet the crew:
🧠 The Call Stack
This is where your code actually runs. When you call a function, it gets pushed onto the stack. When it finishes, it gets popped off.
function greet() {
console.log("Hello!");
}
greet(); // pushed → runs → popped
Simple. Linear. One thing at a time.
🌐 Web APIs (a.k.a. The Kitchen Staff)
When you call something like setTimeout, fetch, or an event listener — JavaScript doesn’t handle that itself. It hands it off to the browser’s Web APIs (or Node’s built-ins, if you’re server-side).
These run outside of your JavaScript thread. They’re doing their thing in the background while your code keeps going.
console.log("1");
setTimeout(() => {
console.log("2");
}, 1000);
console.log("3");
// Output:
// 1
// 3
// 2 ← shows up 1 second later
That delay? The Web API handled it. JavaScript just kept going.
📬 The Callback Queue (Task Queue)
When a Web API finishes (your timer fires, your fetch returns, your click is clicked), it doesn’t immediately run your callback. Instead, it drops the callback into the callback queue — a waiting room.
🔄 The Event Loop Itself
Here’s the loop (literally):
“Is the call stack empty? Cool — grab the next thing from the queue and push it on.”
That’s it. The event loop just watches. When the call stack clears, it pulls from the queue and runs the next task.
Call Stack → empties → Event Loop → grabs from Queue → pushes callback → runs → repeat
A Real Walkthrough
Let’s trace through this code step by step:
console.log("start");
setTimeout(() => {
console.log("timeout!");
}, 0);
console.log("end");
You might expect timeout! to appear between start and end since the delay is 0ms. But the output is:
start
end
timeout!
Why?
console.log("start")→ runs immediately, stack clears.setTimeout(...)→ handed off to the Web API. Even with0ms, it’s async.console.log("end")→ runs immediately, stack clears.- Now the stack is empty. The event loop picks up the callback from the queue.
console.log("timeout!")→ finally runs.
The 0ms delay doesn’t mean “run instantly” — it means “run as soon as the stack is clear.”
Enter the Microtask Queue
Here’s where it gets spicy.
There’s actually two queues. The callback queue you already met. But there’s also a microtask queue — and it has priority.
Microtasks come from:
Promise.then()/.catch()/.finally()queueMicrotask()MutationObserver
The rule is:
After each task, drain the entire microtask queue before moving to the next task.
So microtasks always jump the line.
console.log("1");
setTimeout(() => console.log("2"), 0); // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("4");
// Output:
// 1
// 4
// 3 ← microtask runs before setTimeout!
// 2
Walk through it:
"1"→ runssetTimeout→ queued as a macrotaskPromise.resolve().then(...)→ queued as a microtask"4"→ runs- Stack is empty → drain microtask queue →
"3"runs - Stack is empty again → grab next macrotask →
"2"runs
This is why Promise-based code tends to feel more “immediate” than setTimeout.
Why Does This Actually Matter?
Great question. Here’s where it stops being theory and starts being practical.
1. Debugging Async Code
Ever stared at async code and thought “why is this running out of order?” — now you know. The event loop isn’t random. It’s totally deterministic. You can trace exactly why something runs when it does.
2. Performance
If you block the call stack — say, with a massive for loop or a synchronous JSON.parse on a huge file — the event loop can’t grab anything from the queue. Your UI freezes. Your clicks don’t register. Your users are sad.
// ⚠️ This will freeze the browser for a moment:
for (let i = 0; i < 1_000_000_000; i++) {}
Keep the main thread clear. Break up long tasks. Use workers for heavy lifting.
3. Race Conditions and Ordering
When you have multiple async operations, the event loop decides the order. Knowing how microtasks vs. macrotasks queue up helps you reason about which promise resolves first, why a DOM update happens before or after your callback fires, and so on.
A Mental Model to Keep
Think of it this way:
Your Code (synchronous)
↓ runs completely
Web APIs (async work)
↓ finishes, drops callback
Microtask Queue (promises)
↓ drains completely after each task
Callback Queue (timers, events)
↓ one task at a time
Event Loop
↓ orchestrates all of it
The event loop is the conductor. It never rushes. It never skips. It just keeps asking: “Is the stack empty? What’s next?”
The TL;DR
- JavaScript is single-threaded — one thing at a time.
- Slow stuff (timers, network, events) is handed off to Web APIs.
- When done, callbacks wait in the callback queue.
- The event loop pushes them onto the stack when it’s clear.
- Microtasks (Promises) always run before the next macrotask.
- Blocking the stack = freezing everything. Keep it clean.
Where to Go Next
Once the event loop clicks, a whole new layer of JavaScript opens up:
async/await— syntactic sugar over Promises, obeys the same microtask rulesrequestAnimationFrame— lives in its own special queue, synced to the display refreshqueueMicrotask()— a direct way to schedule microtasks without a Promise- Web Workers — actual parallel threads, for when you need to escape the single-thread model
But honestly? Just knowing what you know now puts you ahead of a lot of developers who’ve been writing JavaScript for years.
The event loop is one of those concepts that rewards the time you spend on it. Not just for interviews — but for those moments at 2am when your async code isn’t doing what you expect, and suddenly it all makes sense.
Happy looping. 🔄