How JavaScript Works: Call Stack, Hoisting & the Event Loop
A peek under the hood — how JavaScript actually runs your code line by line, why some functions seem to exist before you write them, and how async fits in. The #1 interview topic.
What you will learn
- Describe the call stack and execution context
- Explain hoisting and why it surprises beginners
- Sketch the event loop and the order async code runs in
JavaScript does one thing at a time
JavaScript is single-threaded: it has exactly one worker that runs your code, one line at a time, top to bottom. Picture a kitchen with a single cook. To understand any tricky behaviour — odd logging order, async, mysterious undefined — you only need to understand how that one cook is organised.
When JavaScript runs your program, it creates an execution context — think of it as a workspace that holds the variables and the current line for the code running right now. Every time you call a function, a fresh execution context is created for that function’s own variables.
The call stack — a pile of unfinished work
The call stack is the pile of functions currently in progress. When you call a function, it is pushed onto the top of the pile; when it finishes (returns), it is popped off. The cook always works on whatever is on top.
<script>
function third() { return "done"; }
function second() { return third(); }
function first() { return second(); }
document.write(first());
</script>Trace the pile as it grows and shrinks:
first()is called → pushed onto the stack. Stack: [first].- Inside
first,second()is called → pushed. Stack: [first, second]. - Inside
second,third()is called → pushed. Stack: [first, second, third]. thirdreturns "done" → popped off. Stack: [first, second].secondreturns that value → popped. Thenfirstreturns → popped. Stack is empty, and "done" is written.
Note: Output: done (The functions finish in the REVERSE order they were called — last in, first out, just like a stack of plates.)
Watch out: If a function keeps calling itself with no stopping point, the pile grows forever and you hit a "Maximum call stack size exceeded" error — a stack overflow. That is the stack literally running out of room.
Hoisting — why some things exist "early"
Hoisting is JavaScript’s habit of setting up certain names before it runs your code top to bottom. The plain-words version: function declarations are fully available even above where you wrote them, but variables made with let/const are not usable until the line that declares them.
<script>
// Calling sayHi BEFORE its line works — functions are hoisted
document.write(sayHi() + "<br>");
function sayHi() { return "Hello!"; }
// But this would ERROR — let/const are not ready early:
// document.write(score); // ReferenceError
let score = 10;
document.write("score is " + score);
</script>sayHi() is called on the first line yet still works, because function declarations are hoisted — JavaScript registers the whole function before running anything. The commented-out score line would crash, because let score is not usable until its own line runs. This is exactly why mixing up declaration order causes confusing bugs.
Note: Output: Hello! score is 10
Tip: Practical takeaway: declare things before you use them. Relying on hoisting makes code confusing. Knowing hoisting exists, though, explains a whole category of "why is this undefined / not defined?" errors.
The event loop — how async waits without freezing
Here is the puzzle that makes this whole lesson worth it. The single cook cannot stand idle waiting for a slow task (a timer, a network request). So slow tasks are handed off to the browser, and their callbacks are queued to run later — only once the call stack is empty. The mechanism that moves queued callbacks back onto the stack is the event loop.
There are even two queues with a priority order: the microtask queue (Promise callbacks) is always emptied before the macrotask queue (setTimeout callbacks). Watch this famous ordering puzzle.
<p id="log"></p>
<script>
const out = [];
out.push("1: start");
setTimeout(() => {
out.push("4: setTimeout (macrotask)");
document.getElementById("log").textContent = out.join(" | ");
}, 0);
Promise.resolve().then(() => out.push("3: promise (microtask)"));
out.push("2: end");
</script>Even though the timer is set to 0 milliseconds, it runs last. Here is why, in order:
- "1: start" and "2: end" are plain synchronous lines, so they run immediately, in order, on the call stack.
- The
setTimeoutcallback is a macrotask — handed to the browser and queued, never run right away even at 0ms. - The
Promise.thencallback is a microtask — also queued, but the microtask queue has higher priority. - Once the synchronous code finishes and the stack is empty, the event loop drains all microtasks first → "3: promise" runs.
- Only then does it take the next macrotask → "4: setTimeout" runs last.
Note: Output: 1: start | 2: end | 3: promise (microtask) | 4: setTimeout (macrotask) (Synchronous code first, then Promise callbacks, then timers — regardless of the 0ms delay.)
Q. With a setTimeout(fn, 0) and a Promise.then() both queued after synchronous code, which runs first?
✍️ Practice
- On paper, trace the call stack for three nested function calls and check the unwind order.
- Predict the log order of a snippet mixing a
console.log, asetTimeout(…,0)and aPromise.then, then run it to confirm.
🏠 Homework
- Write a short note, in your own words, explaining the call stack and the event loop to a friend who is new to JavaScript.