Call Stack and Event Loop
Understand the call stack, Web APIs, the task queue, the microtask queue, and the event loop that ties them together — the mechanism that actually makes asynchronous JavaScript work.
JavaScript's single thread is managed by a small set of cooperating pieces: the call stack that tracks what's currently running, the browser's Web APIs that handle slow work in the background, two queues that hold finished async work waiting for its turn, and the event loop that constantly checks whether it's time to run that queued work. Understanding these pieces removes the mystery from every callback, promise, and async function you'll ever write.
The call stack
The call stack is a Last-In-First-Out (LIFO) structure that tracks function calls. When a function is called, a new 'frame' is pushed onto the top of the stack; when that function returns, its frame is popped off. The engine only ever executes whatever is on top of the stack.
Because JavaScript has one call stack, it can only run one function at a time. If function A calls function B, B is pushed on top of A and must fully complete (and be popped) before A can continue. This is the concrete mechanism behind 'JavaScript is single-threaded.'
Web APIs (or Node APIs)
Things like setTimeout, DOM event listeners, and fetch are not actually part of the JavaScript language itself — they're provided by the browser (or, in Node.js, by the runtime) as Web APIs. When you call setTimeout(fn, 1000), the JavaScript engine hands the timer off to the browser, which counts down the 1000ms outside of the call stack entirely, and the call stack is immediately free to keep running other code.
Once the browser's timer finishes (or the network request in fetch resolves, or the event fires), the browser doesn't just jump the callback onto the call stack directly — it places it into a queue, to be run later, once the stack is clear.
The task queue (macrotask queue) and the microtask queue
The task queue (sometimes called the macrotask queue) holds callbacks from things like setTimeout, setInterval, and DOM events, waiting for their turn to run.
The microtask queue holds callbacks from promises (.then, .catch, .finally, and code after await) and a few other APIs like queueMicrotask. Microtasks have higher priority than tasks: the event loop always fully drains the entire microtask queue before it processes even a single task from the task queue.
This priority difference matters in practice: if you queue both a setTimeout(fn, 0) and a Promise.resolve().then(fn2), fn2 (the microtask) will always run before fn (the task), even though the timer requested a 0ms delay.
The event loop
The event loop is a continuously running process that asks one simple question, over and over: 'Is the call stack empty?' If it is, the event loop looks first at the microtask queue — if there's anything there, it moves the oldest microtask onto the call stack to run, and keeps doing this until the microtask queue is completely empty. Only then does it take a single task from the task queue and push it onto the stack.
After that one task finishes (and the stack empties again), the cycle repeats: drain all microtasks, then take one task, then drain microtasks again, and so on, for as long as the program runs. This loop is what allows a single-threaded language to handle timers, network responses, and user events without ever blocking.
Code Examples
This example directly demonstrates queue priority. The synchronous lines run first ('1' and '4'). Then, before the event loop even considers the task queue, it fully drains the microtask queue, so the promise's .then callback ('3') runs before the setTimeout callback ('2'), even though the timer was scheduled first in the source code. Expected result: 1: start 4: end 3: promise callback (microtask) 2: setTimeout callback (task)
Promise.resolve().then(...) always jumps ahead of setTimeout(..., 0) because microtasks are drained completely before the next task is taken.
This traces the call stack directly. Calling first() pushes first onto the stack. Inside it, second() is called, pushing second on top of first. Inside second, third() is called, pushing third on top. third() logs and returns (popped), then second logs and returns (popped), then first logs and returns (popped) — a clean Last-In-First-Out unwind. Expected result: third done second done first done If you were to trigger a runtime error deep inside third(), the resulting stack trace you see in devtools lists exactly this chain — third, called by second, called by first — because that is literally the state of the call stack at the moment of the error.
The stack grows as first → second → third, and unwinds in the reverse order: third finishes first, first finishes last.
console.log("1: start");
setTimeout(() => {
console.log("2: setTimeout callback (task)");
}, 0);
Promise.resolve().then(() => {
console.log("3: promise callback (microtask)");
});
console.log("4: end");function first() {
second();
console.log("first done");
}
function second() {
third();
console.log("second done");
}
function third() {
console.log("third done");
}
first();Call stack, Web APIs, queues, and the event loop
The event loop never touches the task queue until the call stack is empty AND the microtask queue has been fully drained — this is why promise callbacks consistently run before setTimeout callbacks scheduled around the same time.
┌──────────────┐
│ Call Stack │ ← engine executes only what's on top
└──────┬───────┘
│ (async calls handed off)
Browser APIs (timers, fetch, DOM events)
│ (when finished, callback is queued)
┌──────────┴──────────┐
↓ ↓
Task Queue Microtask Queue
(setTimeout, (promise .then/.catch/
DOM events) .finally, await, queueMicrotask)
└──────────┬──────────┘
↓
Event Loop
"Is the call stack empty?
If so: drain ALL microtasks first,
then take exactly ONE task."
↓
Call StackImportant Rules
The call stack executes one function frame at a time, in Last-In-First-Out order.
Slow operations (timers, network calls, DOM events) are delegated to Web APIs outside the call stack, so they never block it.
Finished async work is placed into a queue, not run immediately — the task queue for timers/events, the microtask queue for promises.
The event loop only moves queued work onto the call stack once the stack is completely empty.
The microtask queue is always fully drained before the event loop processes the next task — microtasks have strict priority over tasks.
Where this shows up
Understanding the call stack and event loop explains real bugs you'll hit in production: why a UI update queued with a promise appears before one queued with setTimeout, why a long synchronous loop freezes an entire page (it never lets the stack empty, so the event loop never gets a turn), and how to read a stack trace in the browser devtools when debugging a crash.
"setTimeout(fn, 0) and Promise.resolve().then(fn) run in the order they were written in the source code."
Regardless of source order, all queued microtasks (promise callbacks) run before the next task (setTimeout callback) once the call stack is empty.
Queue priority, not source order, determines execution order for already-queued asynchronous callbacks — this is one of the most frequently misunderstood parts of the event loop.
[Multiple Choice] When the call stack becomes empty, what does the event loop do first? A. Immediately take the oldest item from the task queue B. Fully drain the microtask queue before taking anything from the task queue C. Randomly pick between the task queue and microtask queue D. Wait for the call stack to be pushed to again before doing anything
B. Fully drain the microtask queue before taking anything from the task queue — Microtasks (promise callbacks) always have priority: the event loop empties the entire microtask queue before it ever processes a single item from the task queue.
Without running it, predict the console output order of: console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D');
Output: A, D, C, B — synchronous lines A and D run immediately, then the microtask C runs, then the task B runs last.
Synchronous code always runs first, in order. Then the microtask queue (promise) is fully drained before any task queue (setTimeout) item runs.