

JavaScript can only run one instruction at a time, yet a browser tab can fetch data, animate a menu, and respond to a click all in what feels like the same instant. The event loop is the mechanism that makes this possible — it’s not a JavaScript language feature exactly, but a part of the runtime environment (browser or Node.js) that coordinates the call stack with two task queues to keep everything running without blocking the page.
The Call Stack: Where Code Actually Runs
The call stack is a last-in-first-out structure that tracks function execution. When a function is called, it’s pushed onto the stack; when it returns, it’s popped off. If function A calls function B, which calls function C, the stack holds all three until C finishes and returns control back down the chain. Synchronous code runs entirely on the call stack, top to bottom, before the event loop does anything else — this is the part of JavaScript that behaves exactly like any other single-threaded language.
Two Queues, Not One: Microtasks vs. Macrotasks
Once the call stack is empty, the event loop doesn’t just grab whatever’s next indiscriminately — it checks two separate queues with different priority. The macrotask queue (also called the task queue) holds callbacks from setTimeout, setInterval, I/O operations, and UI events. The microtask queue holds promise callbacks (.then(), async/await continuations) and queueMicrotask() calls. The critical rule: the entire microtask queue is drained completely before the next macrotask runs, even if new microtasks get added during that draining process.

Walking Through a Real Example
Consider this snippet, which trips up almost every developer the first time they see it:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');The output is 1, 4, 3, 2 — not 1, 2, 3, 4 as the code order might suggest. Here’s why: the call stack runs the synchronous lines first, logging 1 and 4 immediately while queuing the timeout as a macrotask and the promise callback as a microtask. Once the stack empties, the event loop drains the microtask queue first, logging 3, and only then dequeues the macrotask, logging 2 — even though the timeout was set to 0 milliseconds.
Why This Actually Matters in Practice
Understanding the event loop explains bugs that otherwise look random: why a UI update sometimes flashes before data has loaded, why a chain of .then() calls can execute in a surprising order relative to a setTimeout, and why an infinite chain of microtasks (a promise that resolves itself in a loop) can freeze a page’s rendering entirely, since the browser won’t render between microtasks. If you’ve ever debugged a race condition involving promises and timers, the event loop is almost always the underlying cause.
The key asymmetry to internalize is this: all pending microtasks run before the next macrotask, and the browser won’t even render between microtasks. An infinite chain of microtasks will starve the macrotask queue — and in a browser, that means the UI freezes completely, even though technically nothing is “blocked” in the traditional sense.
Event Loop Rules to Remember
- Synchronous code always runs first, completely, before the event loop touches either queue
- Microtasks (promises, async/await continuations) always run before the next macrotask, no exceptions
- Macrotasks (setTimeout, setInterval, I/O, UI events) run one at a time, with a full microtask drain in between each one
- A
setTimeout(fn, 0)does not run immediately — it still waits for the call stack to clear and the microtask queue to drain first - Browsers and Node.js each implement their own event loop, so subtle timing details can differ between environments
Frequently Asked Questions
Is the event loop part of the JavaScript language itself?
No. JavaScript the language doesn’t define an event loop — it’s implemented by the runtime environment, whether that’s a browser or Node.js. Each environment’s event loop has its own specific behaviors, though the core microtask-before-macrotask rule is consistent.
Why does setTimeout(fn, 0) not run immediately?
Because a macrotask can only run after the call stack is empty and the entire microtask queue has been drained, even a zero-millisecond timeout has to wait its turn behind any pending synchronous code and microtasks.
Can understanding the event loop help me debug real bugs?
Yes, frequently. Race conditions involving promises and timers, unexpected UI rendering order, and pages that freeze despite “non-blocking” async code are almost always explainable once you trace exactly how the call stack and both queues are interacting.
Want more developer guides like this? Explore more Web Development articles.