Menu

JavaScript Event Loop: How Async Code Actually Runs

The mental model behind async JavaScript — the call stack, the task queue, the microtask queue, and how the event loop ties them together.

Single-Threaded, But Not Sequential

JavaScript runs on one thread. There's one call stack, and at any given moment, exactly one function is executing. No two lines of your code ever run in parallel inside the same realm.

That sounds limiting until you remember what JavaScript actually does: fetches data, waits for user clicks, reads files. Most of the "work" is waiting. The event loop is the trick that makes waiting cheap — your code hands a task to the browser or Node, goes back to doing other things, and gets notified when the result is ready.

index.js
Output
Click Run to see the output here.

first and second print in order. third prints after — even though the timeout is 0. That gap is the event loop at work, and understanding why is the whole point of this page.

The Call Stack

Every function call pushes a frame onto the call stack. When the function returns, its frame pops off. The stack is just a stack — last in, first out.

index.js
Output
Click Run to see the output here.

When outer() runs, Node pushes outer, then inner, then pops inner when it returns "done", then pops outer. The stack is empty again. That "empty stack" moment is what the event loop is watching for.

Synchronous code runs start-to-finish on the stack. Nothing async can interrupt it. If you write a while (true) loop, the stack never clears and the page freezes — no clicks, no timers, no promise callbacks. The event loop has nothing to do because it can't get a turn.

Where Async Work Actually Lives

JavaScript itself doesn't know how to make a network request or wait 100 milliseconds. Those APIs belong to the host — the browser or Node. When you call setTimeout(fn, 100), here's what happens:

  1. The timer is registered with the host.
  2. setTimeout returns immediately. The stack keeps running.
  3. 100ms later, the host puts fn on a queue.
  4. When the stack is empty, the event loop takes fn off the queue and runs it.
index.js
Output
Click Run to see the output here.

The timer's callback can't run until the for loop and console.log("end") finish — because the stack isn't empty yet. Timers are a minimum delay, not a guarantee.

Two Queues: Tasks and Microtasks

There isn't just one queue. There are two, and the distinction explains most event-loop surprises.

  • Task queue (sometimes called the macrotask queue): setTimeout, setInterval, I/O callbacks, UI events.
  • Microtask queue: promise callbacks (.then, .catch, .finally), await continuations, and anything scheduled with queueMicrotask.

The rule the event loop follows:

  1. Run one task from the task queue.
  2. Drain the entire microtask queue — every microtask, including ones scheduled while draining.
  3. Render if needed (in browsers).
  4. Go to 1.

Microtasks always run before the next task. That's why this surprises people:

index.js
Output
Click Run to see the output here.

Output order: sync 1, sync 2, promise, timeout. The synchronous code runs first. Then the stack clears. Then the event loop drains microtasks (promise). Only then does it pick up the timer task (timeout).

Microtasks Can Starve Tasks

Because the microtask queue drains completely before the next task, a microtask that keeps scheduling more microtasks will block the task queue forever:

index.js
Output
Click Run to see the output here.

The timer would never fire, because every microtask enqueues another microtask, and the loop never lets the queue drain. Promise chains are safe because each .then only schedules one continuation — but hand-written microtask loops are a foot-gun worth knowing about.

await Is Syntactic Sugar for a Microtask

When you await a promise, the function pauses and the rest of it is scheduled as a microtask to run once the promise settles. Nothing magic — just .then under the hood.

index.js
Output
Click Run to see the output here.

Output: A, C, B. The await hands control back to the caller. console.log("C") runs on the current stack. Then the microtask queue drains and the rest of demo resumes, printing B.

Keep this in mind when reading async code. await isn't blocking — it's yielding.

A Worked Example: Ordering Everything

Put every piece together:

index.js
Output
Click Run to see the output here.

Order:

  1. 1: sync — runs on the stack.
  2. 6: sync — still on the stack.
  3. Stack empties. Microtask queue drains: 3: promise, 5: microtask, then 4: nested microtask (scheduled while draining, still gets picked up).
  4. Next task runs: 2: timeout.

Final output: 1, 6, 3, 5, 4, 2. If you can trace this one, you understand the event loop.

Node Has More Phases

Node's event loop is a superset of the browser model. It has distinct phases — timers, pending I/O callbacks, poll, check, close — and microtasks drain between every phase. setImmediate runs in the check phase, process.nextTick runs before regular microtasks (it has its own even-higher-priority queue).

You don't need the phase chart memorized on day one. The key takeaway is the same as in the browser: synchronous code runs to completion, then microtasks drain, then the loop picks up the next queued callback.

Why It Matters

Once the model clicks, a lot of async code stops being mysterious:

  • A long for loop freezes your UI because the event loop can't get a turn.
  • setTimeout(fn, 0) is a way to defer work until after the current task and microtasks finish.
  • A .then callback that runs "immediately" after an already-resolved promise still waits for the current synchronous code to end.
  • await inside a loop serializes work because each iteration yields to the microtask queue before continuing.

Debugging async code is mostly about asking "what's on the stack right now, and what's queued?" The event loop is the answer.

Next: Callbacks

Before promises and async/await, JavaScript's only tool for async work was the callback — a function you hand to an API to call later. Callbacks are still everywhere (event listeners, Node's core APIs), and understanding them is the foundation for everything else in this chapter.

Frequently Asked Questions

What is the event loop in JavaScript?

It's the mechanism that lets single-threaded JavaScript run async work without blocking. The loop watches the call stack — when it's empty, it pulls the next queued callback and runs it. Timers, I/O, and promise continuations all end up on queues that the event loop drains one item at a time.

Why is JavaScript single-threaded?

The language spec defines one call stack per realm, so your code runs on a single thread. Concurrency comes from the host (browser or Node) handing work off to background APIs — timers, network, file I/O — and queueing a callback when they finish. You never see two pieces of JS running at the same time in the same context.

What's the difference between microtasks and macrotasks?

Microtasks come from promises (.then, await) and queueMicrotask. Macrotasks come from setTimeout, setInterval, I/O, and UI events. After each macrotask finishes, the event loop drains the entire microtask queue before running the next macrotask — which is why a Promise.resolve().then(...) always runs before a setTimeout(..., 0) scheduled at the same moment.

Why does setTimeout with 0ms not run immediately?

setTimeout(fn, 0) doesn't mean 'run now' — it means 'queue fn as a macrotask, earliest after 0ms.' The current synchronous code has to finish, the microtask queue has to drain, and only then does the event loop pick up your timer callback. So 0 is a floor, not a promise.

Learn to code with Coddy

GET STARTED