The Event Loop
Peek under the hood of JavaScript to see exactly how the call stack, Web APIs, and two separate queues work together to make async code possible.
You have probably written code like setTimeout or .then() and noticed that JavaScript keeps running while it "waits" for something. But JavaScript is single-threaded — it can only do one thing at a time. So how does it pull that trick off?
The answer is the event loop, one of the most important mental models you can have as a JavaScript developer. Once you see it clearly, async code stops feeling like magic and starts feeling like a well-organised kitchen. Let's walk through it step by step.
#The Call Stack: JavaScript's To-Do List
Every time JavaScript calls a function, it places a "frame" on top of the call stack. When the function returns, that frame is popped off. JavaScript can only execute whatever is on top of the stack — nothing else runs until that frame is gone.
This is synchronous, blocking execution. The call stack is the only place where code actually runs.
The Stack is a Stack of Plates
Imagine stacking plates. You can only pick up or put down the top plate. You can not grab a plate from the middle without removing the ones on top first. Function calls are added to the top; returns remove from the top. JavaScript always works on the top plate.
function greet(name) {
console.log('Hello, ' + name);
}
function main() {
greet('Alice');
greet('Bob');
}
main();#Web APIs: Offloading the Waiting
JavaScript itself has no timer, no network, no file system. Those live in the Web APIs provided by the browser (or in Node.js, by libuv). When you call setTimeout, JavaScript hands the timer to the browser and immediately pops setTimeout off the stack. The browser counts down in the background — JavaScript keeps running other code.
When the timer fires, the browser does not push the callback directly onto the call stack (that could interrupt running code). Instead, it places the callback into a queue.
#Two Queues: Macrotasks and Microtasks
There are actually two queues, and they have very different priorities:
- Macrotask queue (also called the callback queue): holds callbacks from
setTimeout,setInterval, I/O events, and similar Web APIs. - Microtask queue: holds callbacks from resolved Promises (
.then,.catch,.finally) andqueueMicrotask.
The event loop follows one rule above all others: drain the entire microtask queue before picking up the next macrotask.
VIP Lane vs. Regular Lane
Picture two checkout lanes at a supermarket. The microtask queue is the VIP express lane — every customer already there must finish before the cashier turns to the regular macrotask lane. Promise callbacks cut to the front; setTimeout callbacks wait in the regular line.
#The Event Loop's One Job
The event loop is a simple, infinite loop that does exactly this:
- Run all synchronous code on the call stack until it is empty.
- Drain the entire microtask queue (run every microtask, including any that are added while draining).
- Pick one macrotask from the macrotask queue and run it.
- Drain the microtask queue again.
- Repeat from step 3.
That cycle — one macrotask, then all microtasks, then the next macrotask — is the heartbeat of every JavaScript program.
#Step-by-Step: Reading the Log Order
Here is the classic mixed example. Before reading the explanation, try to predict the order yourself:
console.log('1 - sync start');
setTimeout(() => {
console.log('4 - setTimeout callback');
}, 0);
Promise.resolve()
.then(() => console.log('3 - promise .then'));
console.log('2 - sync end');Let's trace it tick by tick:
Synchronous phase (call stack running): - console.log('1 - sync start') executes immediately. Logs 1. - setTimeout(callback, 0) is called. The browser registers the timer and the callback goes to the Web API layer. Pops off the stack instantly. - Promise.resolve().then(fn) — the promise is already resolved, so fn is immediately scheduled onto the microtask queue. - console.log('2 - sync end') executes immediately. Logs 2. - The call stack is now empty.
Microtask drain (before any macrotask): - The event loop checks the microtask queue. It finds the .then callback. Runs it. Logs 3. - Microtask queue is now empty.
Macrotask pickup: - The event loop picks the setTimeout callback from the macrotask queue. Runs it. Logs 4.
Result: 1, 2, 3, 4. The Promise always beats setTimeout, even at 0ms, because microtasks are drained first.
setTimeout(fn, 0) Does NOT Mean "Immediately"
setTimeout(fn, 0) means "no sooner than 0ms AND only when the call stack is empty AND after all microtasks". In practice, browsers enforce a minimum delay (~4ms) and you still wait for every pending Promise to resolve first. Never rely on setTimeout(0) for precise timing.
#Chained Promises and Nested Microtasks
console.log('start');
Promise.resolve()
.then(() => {
console.log('microtask 1');
return Promise.resolve();
})
.then(() => console.log('microtask 2'));
setTimeout(() => console.log('macrotask'), 0);
console.log('end');Notice that microtask 2 still runs before macrotask. Even though it was added to the microtask queue while the microtask queue was being drained, the rule holds: the event loop keeps draining until the microtask queue is completely empty before moving on.
Starving the Macrotask Queue
If your code keeps adding new microtasks inside .then callbacks in an infinite loop, the macrotask queue will never get a turn. This freezes rendering, timers, and I/O events — the page hangs. Infinite promise chains are just as dangerous as infinite while loops.
Given this code, what is the correct log order? console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D');
#Putting It All Together
The event loop is not complicated once you know the three pieces:
- Call stack — only place code runs; must be empty before anything else can happen.
- Microtask queue — Promise callbacks; completely drained after every task.
- Macrotask queue — setTimeout/setInterval/I/O; one at a time, only after microtasks.
With this model you can predict exactly what any async JavaScript snippet will do, debug mysterious ordering bugs, and write code that behaves the way you intend.
Key takeaways
- JavaScript is single-threaded; the call stack must be empty before async callbacks can run.
- Web APIs (browser or Node.js) handle waiting in the background and push callbacks into queues when ready.
- The microtask queue (Promises) is fully drained before the event loop picks the next macrotask (setTimeout).
- setTimeout(fn, 0) does not mean immediate — it always yields to all pending microtasks first.
- Infinite microtask chains can starve the macrotask queue and freeze the UI just like an infinite loop.
The lesson shows that microtasks (Promises) are drained before the event loop picks up the next macrotask (setTimeout). Predict the exact log order.
console.log('1 - sync start');
setTimeout(() => {
console.log('setTimeout callback');
}, 0);
Promise.resolve()
.then(() => console.log('promise .then'));
console.log('2 - sync end');Chained .then callbacks each schedule the next microtask. The lesson stresses the microtask queue is drained COMPLETELY before any macrotask runs. What is logged?
console.log('start');
Promise.resolve()
.then(() => console.log('microtask 1'))
.then(() => console.log('microtask 2'));
setTimeout(() => console.log('macrotask'), 0);
console.log('end');Fill in the two API names from the lesson: the timer that schedules a MACROTASK, and the object whose .then callback schedules a MICROTASK.
// schedules a macrotask (runs later, after microtasks) (() => console.log('later'), 0); // schedules a microtask (runs before the macrotask above) .resolve().then(() => console.log('sooner'));
Arrange these steps into the event loop's one repeating job, exactly as the lesson describes its cycle.
Pick ONE macrotask from the macrotask queue and run it.
Drain the microtask queue again.
Run all synchronous code on the call stack until it is empty.
Drain the entire microtask queue (run every microtask, including ones added while draining).
Repeat from the 'pick one macrotask' step.
Predict and then verify the log order of the snippet below. Add one more Promise.resolve().then(...) call that logs 'extra microtask' after 'microtask 1' but still before 'timeout'. Run the code mentally using the event loop rules, then write the expected output in a comment at the top.
Try it live — edit the code and hit Run to see the output: