Intro to Asynchronous JavaScript: The Call Stack, the Event Loop and Callbacks
We see how single-threaded JavaScript handles waiting without freezing the page through the call stack, Web APIs, the callback queue and the event loop, and unpack setTimeout(fn, 0) and callback hell.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
Three lines and a timer. The delay is zero milliseconds. In what order do the letters show up in the console?
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
Make a guess before you keep reading. Zero milliseconds sounds like "right now", doesn't it? By the end of this post you'll be able to picture why it isn't, and what is going on inside the engine.
Synchronous Code: One Checkout, One at a Time
Almost all the code we've written so far has been synchronous. Synchronous means the lines run in the order they're written, and one doesn't start until the previous one has finished. Picture a shop with a single checkout: the cashier doesn't move on to the next customer until they've finished scanning the current one's items.
The model is easy to follow and predictable, but it has a weakness. If one customer says "hang on, I left my wallet in the car," everyone behind them waits. In code, the equivalent is a long-running operation. Save the HTML file below, open it in your browser, click "Freeze" and then immediately click "Hello":
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Frozen page</title>
</head>
<body>
<button id="freeze">Freeze (3 seconds)</button>
<button id="hello">Hello</button>
<p id="out"></p>
<script>
const out = document.querySelector("#out");
document.querySelector("#freeze").addEventListener("click", () => {
const end = Date.now() + 3000;
while (Date.now() < end) {
// spin for 3 seconds doing nothing
}
out.textContent = "Loop finished";
});
document.querySelector("#hello").addEventListener("click", () => {
out.textContent = "Hello!";
});
</script>
</body>
</html>
For three seconds the page plays dead. The "Hello" button ignores your click, you can't select text, and sometimes even scrolling stutters. When the loop finishes, the waiting click gets handled. To understand why, we need to look at how the engine works.
A Single Thread and the Call Stack
JavaScript runs on the page's main thread, and there is only one of it. That means only one piece of JavaScript can run at a time. Drawing the page and responding to clicks largely share that same thread too. There's one cashier, and if you keep them busy with a loop, they can't serve anyone else.
The engine keeps track of which function it's currently inside using a structure called the call stack. Think of a stack of plates: when a function is called, a plate goes on top; when the function finishes, that plate comes off. You always work with the plate on top.
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
const result = square(n);
console.log(result);
}
printSquare(4); // 16
When printSquare(4) is called, it goes onto the stack. It calls square, which goes on top. That calls multiply, which rises to the very top. When multiply returns its result it drops off the stack, then square, and finally printSquare. Once the stack is empty, the engine is done with this code.
You've actually seen this before. In the post on error handling, the "stack trace" we saw in the console when an error was thrown is a snapshot of the call stack at that moment: which function the error came from, who called that function, and who called that one. In the frozen-page example above, the while loop sat on top of the stack for three seconds and let nothing else take a turn.
The Browser's Helpers: Web APIs
So how is it possible, with a single thread, to wait for a server's response or show a message five seconds from now? Here's the answer: the waiting isn't done by the JavaScript engine, it's done by the browser.
Functions like setTimeout, addEventListener and fetch are not part of the language itself. They are Web APIs that the browser offers to JavaScript. When you say setTimeout(fn, 2000), the engine tells the browser "remind me about this function in two seconds" and moves on to the next line without waiting at all. The browser keeps the clock.
Think of a restaurant. There's a single waiter (JavaScript's thread). They take your order, pass it to the kitchen, and don't stand at the kitchen door waiting for it to cook; they keep looking after the other tables. The kitchen (the Web APIs) prepares the food and puts it on the counter when it's ready. Once the waiter finishes what they're doing, they check the counter and carry the ready plate to the table.
The Callback Queue and the Event Loop
In this analogy, the counter is the callback queue. When a timer runs out, a click happens or a response arrives, the function to be run doesn't run right away; it's added to the end of this queue and waits its turn.
The event loop is the waiter's habit. Its rule is very simple: when the call stack is empty, take the first function from the queue, put it on the stack and run it. When it's done, check again. This loop keeps going for as long as the page is open.
Now let's return to the opening question and walk through it step by step:
console.log("A"); // 1) runs immediately: A
setTimeout(() => console.log("B"), 0); // 2) handed to the browser, the time is up at once,
// the function enters the queue
console.log("C"); // 3) runs immediately: C
// 4) the script is done, the stack is empty, the event loop checks the queue: B
The output is A, C, B. Even with a delay of zero, the callback goes into the queue, and a function in the queue can't run until the stack is empty. At that point your script is still running on the stack. So setTimeout(fn, 0) doesn't mean "run it now"; it means "run it at the first chance, once the current work is finished."
There's another consequence: the delay you give setTimeout is not a guarantee, it's a minimum. Try this in the console:
const start = Date.now();
setTimeout(() => {
console.log(`Elapsed: ${Date.now() - start} ms`);
}, 100);
while (Date.now() - start < 1000) {
// keep the stack busy for 1 second
}
We asked for 100 milliseconds but we'll see roughly 1000. The timer expired on time and the callback entered the queue on time, but the stack was busy for a whole second, so the waiter couldn't check the counter.
What we've drawn here is only the introductory picture of the event loop. In reality there's more than one queue, promises have their own line, and the browser gets its chance to paint the screen at particular points in this loop. We'll go into those details in the event loop post of the Advanced series; for now, the rule "when the stack is empty, take the next one from the queue" is enough to explain everything.
The Callback Pattern
The oldest way to get the result of asynchronous work is to give the function that starts it another function that says "call this when you're done." As we saw earlier, functions are values; they can be passed to other functions as arguments. We call this a callback.
Let's fake fetching user data from a server with setTimeout. Paste this into your browser console and try it:
function getUser(id, callback) {
setTimeout(() => {
if (id <= 0) {
callback(new Error("Invalid user id"));
return;
}
callback(null, { id, name: "Ellie" });
}, 500);
}
getUser(1, (error, user) => {
if (error) {
console.error(error.message);
return;
}
console.log(`User: ${user.name}`);
});
console.log("Request sent");
First you see "Request sent", and half a second later "User: Ellie". Making the error the first parameter of the callback is a common convention: if there was an error, the first argument is filled in; if not, it's null and the result is in the second argument. That way every callback starts by asking "did something go wrong?"
Callback Hell: Code That Drifts to the Right
For a single piece of asynchronous work, a callback is perfectly fine. The trouble starts when tasks depend on each other. Imagine you want the user first, then their orders, then the details of the first order. With getUser from above still defined in the console, paste this too:
function getOrders(userId, callback) {
setTimeout(() => callback(null, [{ id: 101 }, { id: 102 }]), 500);
}
function getOrderDetail(orderId, callback) {
setTimeout(() => callback(null, { id: orderId, total: 349.9 }), 500);
}
getUser(1, (error, user) => {
if (error) return console.error(error.message);
getOrders(user.id, (error, orders) => {
if (error) return console.error(error.message);
getOrderDetail(orders[0].id, (error, detail) => {
if (error) return console.error(error.message);
console.log(`${user.name} paid ${detail.total} on their first order`);
});
});
});
With every step the code drifts a little further to the right and turns into a pyramid. This is called callback hell, or the "pyramid of doom". Even at three steps it's getting hard to read; at five or six, with loops and conditions mixed in, it becomes a real tangle.
There are two problems more serious than the looks. First, the error check repeats at every level and it's very easy to forget one. Second, you're handing control to someone else: the function you give your callback to might never call it, might call it twice or might call it with the wrong arguments. The flow of your code is no longer in your hands. Promises were born precisely to cure these two headaches.
Common Pitfalls
The most common mistake is trying to return an asynchronous result from a function with return:
function getUserName() {
let name;
setTimeout(() => {
name = "Ellie";
}, 500);
return name;
}
console.log(getUserName()); // undefined
The return name line runs before the timer's callback has even entered the queue. You can only get at an asynchronous result inside the callback, or with promises and async/await, which we'll see in the coming posts.
The second is treating the setTimeout delay as exact. As we saw, the delay is a minimum; if the stack is busy, the callback runs late. Don't rely on it for anything that needs to-the-second timing.
The third is doing long synchronous work on the main thread. Processing a huge array or running a heavy calculation freezes the page, because the event loop can't check the queue in the meantime. That kind of work has to be split into chunks or moved to another thread; we'll see how in the Advanced series.
Finally, the loop trap we talked about in the closures post shows up here as well. If you use setTimeout inside a for loop declared with var, the loop is long over by the time the callbacks run, and they all see the same last value. Use let.
A Small Experiment
★☆☆ Predict the output, then try it in the console:
console.log(1);
setTimeout(() => console.log(2), 100);
setTimeout(() => console.log(3), 0);
console.log(4);
★★☆ Change getOrderDetail so that it returns an "Order not found" error when the order id is 102. Call the third step of the pyramid with orders[1].id and check that the error is caught at the right level.
★★★ Split the 3-second loop from the frozen-page example into 30 chunks of 100 milliseconds each, and after each chunk schedule the next one with setTimeout(nextChunk, 0). Does the "Hello" button respond in between now? Why?
Callbacks get the job done, but the right-drifting pyramid and scattered error checks get tiresome as things grow. In the next post we'll meet promises, which let us hold an asynchronous result as an object, and we'll turn that pyramid into a flat chain.