The Event Loop & Call Stack
The Event Loop & Call Stack
JavaScript is single-threaded — it can only execute one thing at a time. Yet it handles timers, network requests, and user events without freezing the page. The event loop is how this works, and understanding it eliminates an entire class of confusing bugs.
The Call Stack
JavaScript keeps track of function calls with a stack (last in, first out):
function greet(name) {
return `Hello, ${name}!`;
}
function printGreeting() {
const msg = greet("Alice");
console.log(msg);
}
printGreeting();
// Call stack at each step:
// 1. [printGreeting]
// 2. [greet, printGreeting] ← greet pushed on top
// 3. [printGreeting] ← greet returned, popped
// 4. [] ← printGreeting returned
When the stack is empty, JavaScript is idle — ready to pick up the next task from the queue.
Web APIs and the Task Queue
Browsers (and Node.js) provide asynchronous Web APIs that run outside the call stack:
console.log("1 - Start");
setTimeout(() => {
console.log("3 - Timeout");
}, 0);
console.log("2 - End");
// Output: 1, 2, 3
// Even setTimeout(fn, 0) doesn't run immediately!
// The callback goes to the task queue and waits for the call stack to empty
The event loop constantly checks: "Is the call stack empty? If so, move the next task from the queue to the stack."
Microtasks vs Macrotasks
Not all async operations are equal. There are two queues:
Microtask queue (higher priority):
- Promise
.then()/.catch()/.finally()callbacks queueMicrotask()MutationObserver
Macrotask queue (lower priority):
setTimeout/setIntervalcallbacks- DOM events
- I/O, network responses
Rule: Microtasks run to completion before any macrotask runs.
console.log("1");
setTimeout(() => console.log("2 - setTimeout"), 0);
Promise.resolve().then(() => console.log("3 - Promise"));
console.log("4");
// Output: 1, 4, 3, 2
// 1 and 4: synchronous, run immediately
// 3: microtask, runs before setTimeout
// 2: macrotask, runs last
A More Complex Example
console.log("Start");
setTimeout(() => console.log("Timeout 1"), 0);
Promise.resolve()
.then(() => {
console.log("Promise 1");
return Promise.resolve("inner");
})
.then(v => console.log("Promise 2:", v));
setTimeout(() => console.log("Timeout 2"), 0);
console.log("End");
// Output:
// Start
// End
// Promise 1 ← microtask
// Promise 2 ← chained microtask runs before macrotasks
// Timeout 1 ← macrotask
// Timeout 2 ← macrotask
Why Blocking the Stack Is Bad
// This freezes the browser — no UI updates, no events
function blockingOperation() {
const end = Date.now() + 5000;
while (Date.now() < end) { /* spin */ }
console.log("Done — but UI was frozen for 5 seconds!");
}
// The correct approach: break long work into chunks
function nonBlockingOperation(items) {
let i = 0;
function processChunk() {
const end = Date.now() + 5; // process for 5ms max
while (i < items.length && Date.now() < end) {
process(items[i++]);
}
if (i < items.length) {
setTimeout(processChunk, 0); // yield to event loop
} else {
console.log("Done!");
}
}
processChunk();
}
// Modern approach: Web Workers for truly CPU-heavy work
const worker = new Worker("heavy-task.js");
worker.postMessage(data);
worker.onmessage = (e) => console.log("Result:", e.data);
setTimeout and setInterval
// One-time delay
const id = setTimeout(callback, 1000);
clearTimeout(id); // cancel before it fires
// Repeating
const intervalId = setInterval(() => {
updateClock();
}, 1000);
// Stop after some condition
const interval = setInterval(() => {
progress += 10;
if (progress >= 100) {
clearInterval(interval);
onComplete();
}
}, 100);
// More reliable recursion pattern (no drift)
function tick() {
doWork();
setTimeout(tick, 1000); // schedule next after this one completes
}
tick();
requestAnimationFrame
For smooth animations — runs before the browser paints:
function animate() {
// Update your animation state
element.style.left = (x += 2) + "px";
// Schedule next frame (≈60fps, or display refresh rate)
if (x < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
// Cancel
const frameId = requestAnimationFrame(animate);
cancelAnimationFrame(frameId);
queueMicrotask
Schedule a function in the microtask queue without wrapping in a Promise:
console.log("1");
queueMicrotask(() => console.log("3 - microtask"));
console.log("2");
// Output: 1, 2, 3
The Practical Takeaway
// These execute in this order:
// 1. Synchronous code
// 2. Microtasks (promises, queueMicrotask)
// 3. Rendering updates
// 4. Macrotasks (setTimeout, events)
// This matters when:
async function fetchAndRender() {
const data = await fetch("/api/data").then(r => r.json());
// This code runs in the microtask queue after the await resolves
// The DOM won't update between these two lines
updateData(data);
renderUI(data);
}
// setTimeout(fn, 0) is useful for "run after current rendering"
element.classList.add("animating");
setTimeout(() => element.classList.add("running"), 0);
// Forces the browser to paint the first class before adding the second
Next lesson: Callbacks & Callback Hell — the original async pattern and why Promises replaced it.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises