Problem Solved

JavaScript · event loop

Home › Courses › JavaScript › Problems › Event loop
🟨 Concept · 4 min read

How does the event loop work?
Why setTimeout(0) runs last.

JavaScript runs one thing at a time. The event loop is how it juggles asynchronous work without using threads. It watches the call stack and two queues — microtasks and macrotasks — and decides what runs next. The rule is simple but surprising: synchronous code first, then ALL microtasks, then one macrotask. That's why Promise.then always runs before setTimeout(0). This page covers the mechanism, the two queues, and the async/await connection.

Short answer: JavaScript is single-threaded. Synchronous code runs on the call stack. When the stack is empty, the event loop pulls from the microtask queue (promises, queueMicrotask) until it's empty, then runs one macrotask (setTimeout, I/O). This is why console.log('a'); Promise.resolve().then(() => console.log('b')); console.log('c') prints a, c, b. Synchronous first, microtasks after, macrotasks last. await is the same mechanism — it pauses the function and schedules the rest as a microtask.

✅ The two queues

Microtasks before macrotasks. Always.

Call stack

Synchronous code. Runs immediately. LIFO. The event loop only moves on when this is empty.

Microtask queue

Promise.then, queueMicrotask, await continuations, MutationObserver. Drained completely after each task.

Macrotask queue

setTimeout, setInterval, I/O, UI events. One per event loop tick.

The order

1. Run stack until empty. 2. Drain microtasks. 3. Run one macrotask. 4. Repeat.

✅ The output order

The classic event loop question.

order.js
console.log('1');

setTimeout(() => console.log('2'), 0);

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

console.log('4');

// What order does this print?
Actual output:
1
4
3
2

Why, step by step:

1. console.log('1') runs — stack.
2. setTimeout(...) schedules a macrotask.
3. Promise.resolve().then(...) schedules a microtask.
4. console.log('4') runs — stack.
5. Stack empty. Event loop drains microtasks.
6. Microtask runs: console.log('3').
7. Microtask queue empty. Event loop runs
one macrotask.
8. Macrotask runs: console.log('2').

The queue order:
Stack: 1, 4
Microtask: 3
Macrotask: 2

Result: 1, 4, 3, 2.

❌ setTimeout(0) doesn't mean "run immediately."
✅ It means "run after current stack and all microtasks."
✅ Promises always beat timers.
✅ This is not a bug — it's the design.

✅ await is a microtask

async/await uses the same queue.

await-order.js
async function foo() {
console.log('A');
await Promise.resolve();
console.log('B');
}

console.log('start');
foo();
console.log('end');

// Output?
// start, A, end, B
Actual output:
start
A
end
B

Why:

1. console.log('start') — stack.
2. foo() is called.
3. console.log('A') — stack. Still inside foo.
4. await Promise.resolve() — foo pauses here.
The rest of foo (console.log('B')) is
scheduled as a microtask.
5. foo returns control to the caller.
6. console.log('end') — stack.
7. Stack empty. Event loop drains microtasks.
8. Microtask runs: console.log('B').

So even though foo has an await, the code
BEFORE the await runs synchronously, and the
code AFTER runs as a microtask.

✅ await pauses the function, not the stack.
✅ Synchronous code before await runs immediately.
✅ Code after await runs in the microtask queue.
✅ Same order as .then().

This is why async functions don't block:
they yield control back to the event loop
at every await.

await = "pause here, continue as a microtask."

✅ Blocking the event loop

Why synchronous code freezes the page.

blocking.js
// ❌ This blocks everything for 5 seconds
function heavy() {
const start = Date.now();
while (Date.now() - start < 5000) {
// Spin, blocking the stack
}
console.log('done');
}

console.log('before');
heavy();
console.log('after');

// Output: before, (5 seconds of freeze), done, after

// ✅ Breaking up the work
async function chunked() {
const start = Date.now();
while (Date.now() - start < 5000) {
// Do a chunk of work
await new Promise(r => setTimeout(r, 0));
// Yield to the event loop
}
console.log('done');
}
What happens with the blocking version:

1. console.log('before') — stack.
2. heavy() is called. The while loop runs
synchronously for 5 seconds. During this time:
❌ No timer callbacks run.
❌ No UI updates render.
❌ No user input processed.
❌ No promises resolve.
The stack is full.
3. After 5 seconds, heavy returns.
4. console.log('after') — stack.

Result: 'before' → 5-second freeze → 'done' → 'after'
The page is completely unresponsive for 5 seconds.

Why: the event loop only runs when the stack
is empty. A long synchronous loop keeps the
stack full. Nothing else can run.

The fix: yield to the event loop periodically.
Every setTimeout(0) or await gives the event
loop a chance to run pending tasks. For heavy
computation, consider a Web Worker — a separate
thread that doesn't block the main stack.

Rule of thumb: keep synchronous work under 50ms.
If it exceeds 50ms, chunk it or offload it.

The event loop is not multithreading.
It's single-threaded scheduling with yielding.

Don't rely on setTimeout(0) for ordering. It doesn't mean "run next" — it means "run after the current stack and all microtasks." If you have multiple setTimeout(0) calls, they run in the order they were scheduled, but they all run after any pending promises. Use setTimeout(0) when you want to defer work to the next tick, not when you want to guarantee order. For guaranteed ordering, use promises or async/await. The event loop is deterministic but the priority is fixed: microtasks before macrotasks. Design around it, don't fight it.

⚠️ Common mistakes

The event-loop mistakes that cost real time.

1. Expecting setTimeout(0) to run before promises

Microtasks beat macrotasks. Fix: understand the queue order.

2. Blocking the stack with synchronous work

Freezes timers, UI, and input. Fix: chunk with await or use a worker.

3. Assuming await blocks

It pauses the function, not the stack. Fix: understand microtask scheduling.

4. Infinite promise loops

Microtasks schedule more microtasks — the loop never yields. Fix: yield with setTimeout(0) periodically.

5. Timing-based bugs

Relying on when timers fire. Fix: use promises or async/await for ordering.

6. Not understanding the call stack

Confusing synchronous vs asynchronous execution. Fix: trace the stack mentally.

7. Ignoring microtask starvation

A chain of promises can freeze the page. Fix: break chains with setTimeout if needed.

8. Expecting async to run in parallel

It's concurrent, not parallel. Fix: use Promise.all for concurrency, workers for parallelism.

❓ Frequently asked

How does the JavaScript event loop work?

JavaScript is single-threaded — it runs one task at a time on the call stack. When an async operation completes, the callback goes into a queue. The event loop watches the stack and the queue: when the stack is empty, it pulls the next item from the queue and pushes it onto the stack. There are two queues: microtask (promises) and macrotask (setTimeout). The event loop drains microtasks completely before each macrotask.

Why does setTimeout(0) not run first?

Because setTimeout(0) doesn't mean 'run now' — it means 'add to the macrotask queue, to run after the current task and all pending microtasks.' The current synchronous code runs first, then the microtask queue (promises), then the macrotask queue (setTimeout). So console.log('a'); setTimeout(() => console.log('b'), 0); Promise.resolve().then(() => console.log('c')); console.log('d') prints a, d, c, b.

What is the difference between microtask and macrotask?

Microtasks: Promise callbacks, queueMicrotask, MutationObserver, await continuations. Macrotasks: setTimeout, setInterval, I/O callbacks, UI rendering. The event loop drains ALL microtasks after each macrotask before moving to the next macrotask. Microtasks have priority; macrotasks are scheduled for the next tick.

What is the call stack?

The call stack is where JavaScript keeps track of function calls. When you call a function, it's pushed onto the stack. When it returns, it's popped off. The event loop only picks up the next task when the stack is empty — which means a long-running synchronous function blocks everything else.

Does the event loop block on synchronous code?

Yes. JavaScript has only one call stack. If you write a synchronous loop that runs for 5 seconds, nothing else can run for 5 seconds — no timers, no UI updates, no user input. This is why long-running computations need to be broken up: use setTimeout, requestIdleCallback, web workers, or async/await with yields.

What is the relationship between async/await and the event loop?

await is syntactic sugar for promises. When you await a promise, the function pauses at that line, and the rest of the function is scheduled as a microtask to run when the promise resolves. The synchronous code after the await doesn't run immediately — it goes into the microtask queue. async/await doesn't make code concurrent — it makes code non-blocking.

🔗 Continue learning

🗺️ JavaScript learning path
The full roadmap from variables to async — every concept in order

Next in this series:

⏳ Promises vs async/await
When to use each, and why async/await is not a replacement for .then()
← All JavaScript problems
Problem solved · JavaScript