Asynchronous JavaScriptAdvanced9 min12 / 12

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.

Think of it like

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.

main() is pushed, then greet('Alice') is pushed on top, logs, pops off, then greet('Bob') does the same. One at a time, top to bottom.
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) and queueMicrotask.

The event loop follows one rule above all others: drain the entire microtask queue before picking up the next macrotask.

Think of it like

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:

  1. Run all synchronous code on the call stack until it is empty.
  2. Drain the entire microtask queue (run every microtask, including any that are added while draining).
  3. Pick one macrotask from the macrotask queue and run it.
  4. Drain the microtask queue again.
  5. 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:

Even with a 0ms delay, setTimeout logs AFTER the Promise. Here is exactly why.
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.

Common mistake

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

Each .then schedules the next microtask. Both run before the setTimeout macrotask.
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.

Watch out

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.

Quick check

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:

  1. Call stack — only place code runs; must be empty before anything else can happen.
  2. Microtask queue — Promise callbacks; completely drained after every task.
  3. 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.
Practice challenges
Test yourself · earn XP
0/4
Predict the output#1

The lesson shows that microtasks (Promises) are drained before the event loop picks up the next macrotask (setTimeout). Predict the exact log order.

predict-output
console.log('1 - sync start');

setTimeout(() => {
  console.log('setTimeout callback');
}, 0);

Promise.resolve()
  .then(() => console.log('promise .then'));

console.log('2 - sync end');
Predict the output#2

Chained .then callbacks each schedule the next microtask. The lesson stresses the microtask queue is drained COMPLETELY before any macrotask runs. What is logged?

predict-output
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 blank#3

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'));
Reorder the lines#4

Arrange these steps into the event loop's one repeating job, exactly as the lesson describes its cycle.

1
Pick ONE macrotask from the macrotask queue and run it.
2
Drain the microtask queue again.
3
Run all synchronous code on the call stack until it is empty.
4
Drain the entire microtask queue (run every microtask, including ones added while draining).
5
Repeat from the 'pick one macrotask' step.
Your turn
Practice exercise

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:

solution.js · editable