JavaScript runs on a single thread — it can only do one thing at a time. Yet it routinely handles timers, network requests, and user clicks without freezing the page. The mechanism that makes this possible is the event loop, and understanding it is what finally makes asynchronous code (callbacks, Promises, setTimeout) make sense instead of feeling like magic.

There are three pieces to know: the call stack (where your running code actually executes), the Web APIs (browser-provided features like timers and network requests that run outside your JS code), and the queues that hold callbacks waiting to run once the stack is empty.

The Call Stack

Every function call gets pushed onto the call stack, and popped off when it returns. JavaScript only moves to new code once the stack is empty — this is what "single-threaded" means in practice.

function a() { b(); }
function b() { console.log("inside b"); }

a(); // a() is pushed, calls b(), b() is pushed, logs, pops, a() pops

A Classic Example: setTimeout(fn, 0)

This confuses almost everyone the first time they see it:

console.log("1 - start");

setTimeout(() => {
  console.log("2 - timeout callback");
}, 0);

console.log("3 - end");

Output:

1 - start
3 - end
2 - timeout callback

Even with a 0ms delay, the timeout callback runs last. Here's why: setTimeout hands its callback off to a Web API (the browser's timer system), not the call stack. The main script (console.log("1...") and console.log("3...")) keeps running synchronously to completion first. Only once the call stack is completely empty does the event loop check the task queue, see the timer callback waiting there, and push it onto the stack to run.

Task Queue vs Microtask Queue

There are actually two queues, and they aren't treated equally. Microtasks — Promise callbacks (.then(), async/await continuations) — always run before the next task (setTimeout, setInterval, DOM events), even if the task was "ready" first:

console.log("start");

setTimeout(() => console.log("timeout (task)"), 0);

Promise.resolve().then(() => console.log("promise (microtask)"));

console.log("end");

Output:

start
end
promise (microtask)
timeout (task)

The event loop always drains the entire microtask queue before picking up even one task from the task queue. This is why Promise-based code (including async/await) tends to resolve sooner than you'd naively expect compared to a timer.

Why This Matters

You don't need to memorize the internals to write JavaScript, but understanding the event loop explains real bugs: why a variable isn't updated "yet" right after an async call, why UI updates can feel delayed behind a flood of Promise callbacks, and why mixing setTimeout-based polling with Promise chains can produce surprising orderings. It's also a near-universal interview question once you're past the basics.