JavaScript Event Loop Explained: Call Stack, Callbacks, Promises and async/await
The JavaScript event loop is one of the most misunderstood parts of the language β and one of the most common interview topics. Once you truly understand it, a whole category of bugs and confusing behaviors suddenly makes sense.
Let's build up the mental model from scratch.
JavaScript Is Single-Threaded
JavaScript runs on a single thread. It can only do one thing at a time. Yet we write code that fetches data, waits for timers, and handles user clicks β all seemingly at once.
How? The event loop.
The event loop's job: Keep checking if the call stack is empty. If it is, take the next task from the queue and push it onto the stack.
The Call Stack
The call stack is where JavaScript keeps track of what function is currently executing.
javascriptfunction greet(name) { return `Hello, ${name}`; } function main() { const message = greet("Alice"); console.log(message); } main();
Here is what happens on the stack:
main()pushed onto stackgreet("Alice")pushed onto stackgreetreturns β popped off stackconsole.logpushed, executes, poppedmainreturns β popped off stack- Stack is empty
When the stack is empty, the event loop can push the next task.
Stack overflow
If a function calls itself infinitely with no base case, the stack fills up:
javascriptfunction infinite() { return infinite(); // RangeError: Maximum call stack size exceeded }
Web APIs and Async Operations
When you call setTimeout, fetch, or add an event listener, you are handing work off to Web APIs β provided by the browser, not JavaScript itself.
javascriptconsole.log("1"); setTimeout(() => { console.log("2"); }, 0); console.log("3");
Output: 1, 3, 2
Even with 0ms delay, the callback goes through the Web API, then the task queue, then the event loop. It can only run after the current call stack is empty.
The Task Queue (Macrotask Queue)
When async operations complete, their callbacks go into the task queue (also called the macrotask queue). The event loop picks from here only when the call stack is empty.
Macrotasks include:
setTimeoutcallbackssetIntervalcallbacks- DOM events (click, keypress)
MessageChannel
The Microtask Queue
Promises introduced a microtask queue β a higher-priority queue that is drained completely before the event loop picks the next macrotask.
Microtasks include:
.then()/.catch()/.finally()callbacksawaitcontinuationsqueueMicrotask()MutationObserver
javascriptconsole.log("1"); setTimeout(() => console.log("setTimeout"), 0); Promise.resolve().then(() => console.log("Promise")); console.log("2");
Output: 1, 2, Promise, setTimeout
After the synchronous code runs (1, 2), the microtask queue is drained first (Promise), then the task queue (setTimeout).
Visualizing the Full Flow
codeCall Stack +----------+ | | <-- Currently executing +----------+ ^ | Event Loop picks next task | +-----------------+ +------------------+ | Microtask Queue | | Task Queue | | (Promises etc) | | (setTimeout etc) | | Drained first | | Picked next | +-----------------+ +------------------+ ^ ^ | | +----+-------------------------+------+ | Web APIs | | (timers, fetch, event listeners) | +-------------------------------------+
Promises
Promises represent the eventual result of an async operation.
javascriptfetch("https://api.example.com/data") .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error(error));
Each .then() callback is a microtask β it runs after the current synchronous code but before any setTimeout.
Promise states
- Pending β initial state, not yet settled
- Fulfilled β operation succeeded,
.then()runs - Rejected β operation failed,
.catch()runs
Once settled, a Promise's state never changes.
async/await
async/await is syntactic sugar over Promises. It makes async code look and behave more like synchronous code.
javascriptasync function getUser(id) { try { const response = await fetch(`/api/users/${id}`); const user = await response.json(); return user; } catch (error) { console.error("Failed:", error); } }
await pauses execution of the async function and yields control back to the event loop until the Promise settles. The function resumes as a microtask.
Common async/await mistake: forgetting await
javascriptasync function bad() { const data = fetch("/api/data"); // missing await! console.log(data); // logs a Promise object, not the data }
Running Promises in parallel
javascript// Sequential β slow (waits for each before starting next) const a = await fetchA(); const b = await fetchB(); // Parallel β fast (both start at the same time) const [a, b] = await Promise.all([fetchA(), fetchB()]);
Use Promise.all when the operations are independent of each other.
Classic Interview Questions
What is the output?
javascriptconsole.log("start"); setTimeout(() => console.log("timeout 1"), 0); Promise.resolve() .then(() => console.log("promise 1")) .then(() => console.log("promise 2")); setTimeout(() => console.log("timeout 2"), 0); console.log("end");
Answer:
codestart end promise 1 promise 2 timeout 1 timeout 2
Synchronous code runs first (start, end). Then microtasks drain completely (promise 1, then promise 2 β queued when promise 1 runs). Then macrotasks (timeout 1, timeout 2).
Why does this not work as expected?
javascriptfor (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // Prints: 3, 3, 3
var is function-scoped. By the time the callbacks run, the loop is done and i is 3. Fix with let (block-scoped):
javascriptfor (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // Prints: 0, 1, 2
Summary
- JavaScript is single-threaded β one thing at a time
- The call stack tracks executing functions
- Async work goes to Web APIs, then queues
- Microtask queue (Promises, await) drains before the next macrotask
- Task queue (setTimeout, events) runs after microtasks
async/awaitis syntactic sugar over Promises
Understanding the event loop makes you a significantly better JavaScript developer. You will debug async code faster and write it with more confidence.
Test your JavaScript knowledge on Froquiz β we cover the event loop, closures, prototypes, and much more.