Event Loop
Learn how JavaScript handles async operations through the event loop, call stack, and task queues.
Event Loop
You already know JavaScript is single-threaded — it can only do one thing at a time. And you know it handles async operations without freezing. But how exactly does that work?
The answer is the event loop — the system that coordinates everything JavaScript does.
Understanding the event loop explains why code sometimes runs in a different order than you expect, why some async operations run before others, and how JavaScript stays responsive while handling multiple operations.
The Four Parts
The event loop system has four key components:
- Call Stack — where code actually runs, one frame at a time
- Web APIs — browser handles async operations here — timers, network requests, events
- Microtask Queue — high priority callbacks — Promise
.then(),queueMicrotask() - Task Queue (Macrotask Queue) — lower priority callbacks —
setTimeout,setInterval, DOM events
The Call Stack
The call stack is a data structure that tracks which function is currently running. When a function is called, it is pushed onto the stack. When it returns, it is popped off.
function greet(name) {
return `Hello, ${name}`;
}
function main() {
const message = greet("Ali");
console.log(message);
}
main();The stack looks like this as it executes:
main() called → [main]
greet() called → [main, greet]
greet() returns → [main]
console.log() → [main, console.log]
console.log done → [main]
main() returns → []JavaScript can only execute one frame at a time. The stack must be empty before the event loop can push the next task onto it.
How Async Operations Work
When you call an async operation — setTimeout, fetch, an event listener — JavaScript hands it to the Web APIs. The Web API handles it in the background while JavaScript keeps running.
console.log("1 — start");
setTimeout(() => {
console.log("3 — timeout");
}, 0);
console.log("2 — end");
// 1 — start
// 2 — end
// 3 — timeoutEven with a 0ms delay, "3 — timeout" prints last. Here is exactly why:
1. console.log("1") → runs on stack → prints "1 — start"
2. setTimeout() → handed to Web API → Web API starts 0ms timer
3. console.log("2") → runs on stack → prints "2 — end"
4. Stack is now empty
5. Timer finished → callback moved to Task Queue
6. Event loop sees empty stack → moves callback to stack
7. Callback runs → prints "3 — timeout"The callback cannot run until the stack is empty — even with a 0ms delay.
Microtasks vs Macrotasks
This is the most important distinction in the event loop. There are two queues — and they have different priorities.
Macrotasks (Task Queue):
setTimeoutsetInterval- DOM events
setImmediate(Node.js)
Microtasks (Microtask Queue):
- Promise
.then(),.catch(),.finally() queueMicrotask()MutationObserverawaitcontinuations
The rule — after every macrotask, JavaScript drains the entire microtask queue before picking up the next macrotask.
console.log("1 — start");
setTimeout(() => console.log("4 — setTimeout"), 0); // macrotask
Promise.resolve()
.then(() => console.log("3 — Promise then")); // microtask
console.log("2 — end");
// 1 — start
// 2 — end
// 3 — Promise then
// 4 — setTimeoutBoth setTimeout and the Promise are async — but the Promise resolves first because microtasks have higher priority than macrotasks.
The Event Loop in Detail
Here is the exact algorithm the event loop follows:
Key insight — microtasks are completely drained before any macrotask runs. If a microtask adds another microtask, that also runs before any macrotask.
A Detailed Example
console.log("script start"); // 1
setTimeout(() => {
console.log("setTimeout"); // 6
}, 0);
Promise.resolve()
.then(() => {
console.log("promise 1"); // 4
})
.then(() => {
console.log("promise 2"); // 5
});
console.log("script end"); // 2 — wait, where is 3?Wait — what is 3? Let's trace carefully:
Synchronous code runs first:
1. "script start"
2. setTimeout registered → Web API
3. Promise.resolve() created → .then callbacks scheduled as microtasks
4. "script end"
Stack is now empty. Check microtask queue:
5. Run "promise 1" → .then returns → schedules "promise 2" as microtask
6. Run "promise 2"
Microtask queue empty. Check task queue:
7. Run setTimeout callback → "setTimeout"Output:
script start
script end
promise 1
promise 2
setTimeoutNotice — "promise 2" runs before "setTimeout" even though "promise 2" was not scheduled until "promise 1" ran. All microtasks drain completely before any macrotask runs.
async/await and the Event Loop
await pauses the async function and yields control back to the caller — everything after the await becomes a microtask.
async function fetchData() {
console.log("2 — inside async function");
await Promise.resolve();
console.log("4 — after await"); // scheduled as microtask
}
console.log("1 — before async call");
fetchData();
console.log("3 — after async call");
// 1 — before async call
// 2 — inside async function
// 3 — after async call
// 4 — after awaitfetchData() runs synchronously until it hits await. Then it pauses — returns control to the caller — and the rest of fetchData is scheduled as a microtask. The synchronous "3 — after async call" runs before the microtask continues.
Blocking the Event Loop
If synchronous code takes too long, it blocks the entire event loop — no callbacks fire, no UI updates, nothing responds.
// ❌ Blocks the event loop
function blockingOperation() {
const start = Date.now();
while (Date.now() - start < 3000) {
// spin for 3 seconds — nothing else can run
}
console.log("Done blocking");
}
setTimeout(() => console.log("This should fire after 1s"), 1000);
blockingOperation(); // ← blocks for 3 seconds
// "Done blocking" prints at 3s
// "This should fire after 1s" ALSO prints at 3s — it was stuck waitingThe timer callback was ready at 1 second — but the call stack was not empty until 3 seconds. The event loop could not process it.
How to avoid blocking
// ✅ Break heavy work into chunks with setTimeout
function processChunk(data, index = 0) {
const chunkSize = 1000;
const end = Math.min(index + chunkSize, data.length);
for (let i = index; i < end; i++) {
heavyProcess(data[i]);
}
if (end < data.length) {
setTimeout(() => processChunk(data, end), 0);
// yields to event loop between chunks
}
}
// ✅ Use Web Workers for truly CPU-intensive work
const worker = new Worker("heavy-task.js");
worker.postMessage(largeData);
worker.onmessage = (e) => console.log("Result:", e.data);setTimeout(() => ..., 0) yields to the event loop between chunks — letting other callbacks run.
queueMicrotask() — Schedule a Microtask Directly
console.log("1");
queueMicrotask(() => {
console.log("3 — microtask");
});
console.log("2");
// 1
// 2
// 3 — microtaskqueueMicrotask() directly schedules a function in the microtask queue — without needing a Promise. Useful when you need guaranteed microtask timing without creating a Promise.
setTimeout vs setInterval vs requestAnimationFrame
// setTimeout — run once after delay
setTimeout(() => console.log("once"), 1000);
// setInterval — run repeatedly
const id = setInterval(() => console.log("repeat"), 1000);
clearInterval(id); // stop it
// requestAnimationFrame — run before next repaint — 60fps
function animate() {
updateAnimation();
requestAnimationFrame(animate); // schedule next frame
}
requestAnimationFrame(animate);requestAnimationFrame is the right tool for animations — it runs in sync with the browser's repaint cycle (usually 60 times per second) giving you smooth animations. setTimeout has timing imprecision and does not sync with repaints.
A Real Example — Understanding Execution Order
async function loadDashboard() {
console.log("1 — loadDashboard starts");
const userPromise = fetch("/api/user"); // Web API — starts immediately
const postsPromise = fetch("/api/posts"); // Web API — starts immediately
console.log("2 — both fetches started");
const userResponse = await userPromise; // pause — wait for user fetch
console.log("3 — user response received");
const user = await userResponse.json(); // pause — parse JSON
console.log("4 — user data parsed");
const postsResponse = await postsPromise; // probably already done
console.log("5 — posts response received");
const posts = await postsResponse.json();
console.log("6 — posts data parsed");
return { user, posts };
}
console.log("A — before loadDashboard");
loadDashboard();
console.log("B — after loadDashboard call");
// A — before loadDashboard
// 1 — loadDashboard starts
// 2 — both fetches started
// B — after loadDashboard call
// (when fetches complete — back as microtasks)
// 3 — user response received
// 4 — user data parsed
// 5 — posts response received
// 6 — posts data parsed"B — after loadDashboard call" prints before the fetch results because loadDashboard pauses at the first await and returns control to the caller. Both fetches are already running in the Web API layer.
Common Event Loop Interview Questions
Why does this print in this order?
setTimeout(() => console.log("A"), 0);
Promise.resolve().then(() => console.log("B"));
console.log("C");
// C — synchronous
// B — microtask (higher priority than macrotask)
// A — macrotaskCan microtasks block the event loop?
// ❌ Yes — infinite microtasks starve macrotasks
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks);
}
setTimeout(() => console.log("Never runs"), 0);
infiniteMicrotasks(); // macrotask never gets a chanceThe microtask queue never empties — the macrotask never runs. This is why you should not create recursive microtasks.
Summary
- JavaScript is single-threaded — the call stack runs one thing at a time
- Async operations are handed to Web APIs — the browser handles them in the background
- When done, callbacks are placed in either the microtask queue or task queue
- Microtasks — Promise callbacks,
queueMicrotask()— run before any macrotask - Macrotasks —
setTimeout,setInterval, DOM events — one per event loop cycle - After every macrotask, all microtasks are drained before the next macrotask
awaitpauses an async function and schedules the rest as a microtask- Never block the call stack — break heavy work into chunks or use Web Workers
setTimeout(fn, 0)does not run immediately — it runs after the current stack and all microtasks are donerequestAnimationFrameis the right tool for animations — syncs with the browser's repaint cycle