JavaScript140 min total · 15 parts
JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop
Part 8 of 15 · ~3 min
The Call Stack and Stack Overflows
Every mechanism so far has been about which code can see what. This chapter is about something different: what is running right now, and what is waiting on it.
There's exactly one call stack, and JavaScript never runs more than that one thread — the stack is just a record, last-in-first-out, of whatever function is currently running and who's waiting on it to return. Our search box gives us a real one to look at. Someone types a character:
function handleInput(event) { // 1. the browser calls this
const query = normalize(event.target.value); // 2. pushes normalize
const matches = search(query); // 3. pushes search
renderRows(matches); // 4. pushes renderRows
}
handleInput handleInput handleInput
→ normalize → search → renderRows
[stack grows] [returns, pops] [returns, pops] [returns, pops]
Every call pushes a stack frame — the function's parameters, its local variables, and where to resume when it returns. Every return pops one. The stack is how JavaScript knows where to go back to.
And because there is exactly one of them, JavaScript can only be doing one thing at a time. That single fact is the root of nearly everything in the second half of this reference. There is no parallelism for ordinary JavaScript. Asynchrony, when we get to it, is a scheduling trick layered on top of this one stack — never a second stack running alongside it.
The stack has a size limit, and you can hit it. Here is how, from a change that looks entirely innocent — showing a result count above the dropdown:
function renderRows(matches) {
listEl.innerHTML = matches.map(rowHtml).join("");
updateResultCount(matches);
}
function updateResultCount(matches) {
countEl.textContent = `${matches.length} results`;
renderRows(matches); // "just re-render so the count and list stay in sync"
}
// RangeError: Maximum call stack size exceeded
renderRows calls updateResultCount, which calls renderRows, and neither ever returns, so no frame ever pops. The stack grows until it runs out of room. Recursion is not the problem here — recursion is fine, and a recursive walk over a nested article tree would be the natural way to write it. What matters is that every recursive path reaches a base case that returns without recursing. This one has no base case at all; it is a loop wearing recursion's clothes.
The more common consequence of a single stack, though, is not a crash. It is a freeze. Watch what happens when the article index gets big and the scoring gets expensive:
function handleInput(event) {
spinnerEl.hidden = false; // "show the spinner"
const matches = fuzzyScore(ARTICLES, event.target.value); // 400ms of synchronous work
renderRows(matches);
spinnerEl.hidden = true; // "hide the spinner"
}
The spinner never appears. Not for 400ms, not for one frame — never.
handleInput is on the stack for that entire 400ms, and nothing else can run while a frame is sitting there. Not the browser's repaint, so the hidden = false you set on line one never makes it to the screen. Not the click on the button the user is now stabbing. Not a timer, not a network callback, nothing. By the time the stack finally empties and the browser gets a chance to paint, hidden is back to true and there was never anything to show.
This is the thing people mean by "blocking the main thread", and it is worth seeing in a concrete frozen-spinner form rather than as a slogan, because it is the exact problem the entire async half of JavaScript exists to solve. The stack has to empty before anything else gets a turn — so if you want something else to get a turn, you have to give the stack back.
Which raises the obvious question: if there is only one stack and one thread, how does a network request possibly work? Where does the response go while the stack is busy, and who decides when it runs? That is the event loop, and it is where this reference goes next.