The Event Loop
Learn how JavaScript manages asynchronous work on a single thread — the call stack, Web APIs, the macrotask and microtask queues, and the event loop that ties them all together.
Reading Time
20 min
Lesson
Lesson 47 of 48
JavaScript Is Single-Threaded
JavaScript runs on a single thread, meaning it has exactly one call stack and can only do one thing at a time. There's no built-in way for two pieces of your JavaScript code to run at the exact same instant. This might seem like it should make asynchronous code — like the Promises and async/await from the last two lessons — impossible, but it doesn't. The trick is that JavaScript doesn't handle asynchronous work by itself; it hands that work off to the surrounding environment (the browser or Node.js) and gets notified later when the work is done. The event loop is the mechanism that makes this handoff and notification system work.
The Call Stack
The call stack is where your synchronous code actually executes, one function call at a time. When a function is called, a new frame is pushed onto the stack; when it returns, that frame is popped off. As long as the stack has frames on it, the thread is busy — nothing else, including any pending asynchronous callback, can run. This is why a long-running synchronous loop can freeze an entire page: the stack never empties, so nothing waiting behind it gets a turn.
A Blocking Synchronous Call
Console Output
Click “Run” to see the console output here.
Web APIs: Where Async Work Actually Happens
Things like setTimeout(), fetch(), and DOM event listeners are not part of the JavaScript language itself — they're provided by the host environment (Web APIs in a browser, similar APIs in Node.js). When you call setTimeout(fn, 1000), JavaScript hands the timer off to the environment and immediately moves on to the next line; it does not sit on the call stack waiting. The environment manages the timer, the network request, or the event listener in the background, completely outside the call stack, and only comes back to JavaScript once that work is ready to be handled.
The Callback (Macrotask) Queue
When a Web API finishes its work — a timer expires, a network response arrives, a click happens — it doesn't jump straight onto the call stack. Instead, its callback is placed into a queue, often called the callback queue or macrotask queue. Other macrotasks include setTimeout/setInterval callbacks, DOM events, and I/O in Node.js. These callbacks wait in line until the call stack is completely empty.
The Microtask Queue
Promises use a separate, higher-priority queue called the microtask queue. Callbacks passed to .then(), .catch(), .finally(), as well as callbacks scheduled with queueMicrotask(), all land here instead of the macrotask queue. The microtask queue is checked more frequently than the macrotask queue — specifically, it is fully drained after every single synchronous chunk of code finishes, and after every individual macrotask, before the event loop ever looks at the macrotask queue again.
The Event Loop's Job
The event loop is a continuously running process with one simple job: check whether the call stack is empty, and if it is, pull the next task in and push it onto the stack to execute. Its priority order is strict: first, run all synchronous code to completion. Then, before doing anything else, drain the entire microtask queue — every microtask, including any new microtasks queued by earlier ones. Only once the microtask queue is completely empty does the event loop take a single task from the macrotask queue, run it, and then immediately drain the microtask queue again before taking the next macrotask.
Queue Comparison
| Queue | Examples | Priority |
|---|---|---|
| Call stack | Regular synchronous function calls | Runs first, always, until empty |
| Microtask queue | Promise .then()/.catch()/.finally(), queueMicrotask() | Fully drained before every macrotask |
| Macrotask queue | setTimeout, setInterval, DOM events, I/O | One task at a time, only when stack and microtasks are empty |
Why Microtasks Drain Completely First
This design exists so that Promise-based work resolves as promptly and predictably as possible relative to other pending work — a chain of chained .then() calls all gets a chance to run before the event loop moves on to, say, the next timer or rendering update. The consequence is that if a microtask keeps scheduling more microtasks, the event loop will keep draining them indefinitely and the macrotask queue will starve, never getting a turn. This is rare in practice but explains why misusing recursive Promise chains can make timers appear to never fire.
The Classic Ordering Example
Console Output
Click “Run” to see the console output here.
Walking Through the Example
Even though setTimeout(fn, 0) asks to run "immediately," it still has to wait: it's a macrotask, so it can't run until all synchronous code finishes AND the microtask queue is empty. Here's the sequence: the two console.log() calls at the top and bottom run first because they're plain synchronous code on the call stack. The setTimeout callback is handed to the Web API and, once its 0ms timer expires, its callback is placed on the macrotask queue — it does not run yet. The Promise.resolve().then() callback is placed on the microtask queue. Once the synchronous code finishes and the stack is empty, the event loop drains the microtask queue first (log 3), and only then does it pull the one macrotask off the queue (log 2). This is why the output reads 1, 4, 3, 2 instead of the top-to-bottom order 1, 2, 3, 4 you might expect from reading the source alone.
queueMicrotask() Directly
Console Output
Click “Run” to see the console output here.
setTimeout(fn, 0) Does Not Mean "Run Now"
A delay of 0ms only means the timer expires as soon as possible — it still has to wait for the current synchronous code to finish and for the entire microtask queue to drain before its callback can run. "Zero milliseconds" is a minimum, not a guarantee of immediacy.
Why This Matters for Debugging
Understanding the event loop is what lets you predict — rather than guess — the order in which asynchronous callbacks fire. Bugs like a value being read before a Promise has actually resolved, a UI update happening in the wrong order relative to a timer, or a callback firing later than expected almost always trace back to a misunderstanding of the call stack, the microtask queue, and the macrotask queue. Once you can mentally trace through which queue a piece of code lands in and when the event loop will get to it, these ordering bugs become predictable instead of mysterious.
A Mental Model You Can Reuse
When reading async code, ask three questions in order: (1) What runs synchronously, right now, on the call stack? (2) What Promise-related callbacks get queued as microtasks, and in what order? (3) What timer or event callbacks get queued as macrotasks? Then remember the rule: all synchronous code runs, then all microtasks drain, then one macrotask runs, then microtasks drain again, repeating for every macrotask.
A Busy Microtask Queue Can Starve Timers
If code inside a .then() callback keeps scheduling more microtasks (for example, resolving another promise and chaining onto it, over and over), the event loop will keep draining the microtask queue and never reach the macrotask queue. In practice this means setTimeout callbacks, and even things like rendering, can be indefinitely delayed by runaway microtask chains.