Out of Loop

Inside the JavaScript Event Loop

A step-by-step look at how JavaScript runs asynchronous code.

JavaScript has a famous limitation: it runs on a single thread. It can only do one thing at a time. And yet a web page can fetch data, run animations and respond to your clicks all at once, without ever seeming to freeze.

So how does something that can only do one thing at a time appear to do everything at once? The answer is a small, clever piece of machinery called the event loop.

Here's a puzzle to get us started. What does this code print?

console.log("A");setTimeout(() => console.log("B"), 0);Promise.resolve().then(() => console.log("C"));console.log("D");

If you said A B C D, you're in good company — and you're also wrong. By the end of this post, you'll know exactly why the answer is A D C B.

This post is about the event loop in the browser. Node.js follows the same core idea, but it has a few extra phases of its own that we won't cover here.

One thing at a time

Every time JavaScript calls a function, it pushes a frame onto the call stack. When the function returns, the frame is popped off. Whatever is on top of the stack is the one thing JavaScript is doing right now.

function greet(name) {  return `Hello, ${name}!`;}function main() {  const message = greet("world");  console.log(message);}main();

Running this, the stack grows to main → greet, shrinks back to main, grows to main → console.log, and finally empties out. Nothing surprising — as long as every function returns quickly.

Problem

What happens when we need to wait for something slow, like a timer or a network request, without freezing the stack?

Waiting without blocking

The trick is that JavaScript doesn't do the waiting itself. When you call setTimeout, you hand your callback to the browser and move on. The browser keeps track of the timer, and once it fires, it places your callback in a task queue.

Your callback sits in that queue until the call stack is completely empty. Only then does the event loop pick it up and run it. That's the whole algorithm, more or less:

while (true) {  const task = taskQueue.shift();  if (task) run(task);  runAllMicrotasks();  maybeRender();}

Notice the runAllMicrotasks() line. Promises don't use the task queue — they use a separate, higher-priority microtask queue. After every task, the event loop runs every microtask before it will even look at the next task.

Stepping through it

Let's go back to our puzzle and run it one step at a time. Keep an eye on the three boxes at the bottom — they're the whole story.

Code

1console.log("A");2setTimeout(() => console.log("B"), 0);3Promise.resolve().then(() => console.log("C"));4console.log("D");

Console

Nothing printed yet

Call stack

  • script

Microtask queue

Empty

Task queue

Empty

Step 1/9. The whole script is the first task. It gets pushed onto the call stack and starts running from the top.

Step through the snippet to see how the call stack and the two queues change.

Here's what we learned:

Why it matters

Once you know the rules, a lot of strange browser behaviour starts to make sense. A long-running loop blocks clicks because the event loop never gets a turn. A setTimeout(fn, 0) is a way to say "run this after the current work is done". And an infinite chain of promises can freeze a page just as badly as a while (true), because microtasks are drained completely before the browser is allowed to render.

The event loop isn't magic — it's a queue, a stack and a very patient while loop. Now you're no longer out of the loop.