Skip to content
JavaScript js async 5 min read

The Event Loop

JavaScript runs on a single thread, yet it juggles timers, network requests, and user input without ever blocking. The mechanism that makes this possible is the event loop: a coordinator that decides which piece of queued work runs next on that single thread. Understanding it explains a lot of “surprising” behaviour — why a Promise callback fires before a setTimeout(fn, 0), why a long loop freezes the page, and why async code never truly runs “at the same time.”

The single thread and the call stack

At any moment, JavaScript executes exactly one function. The call stack is the data structure that tracks where you are: calling a function pushes a frame, returning pops it. When the stack is empty, the engine is idle and free to pick up new work.

function third() {
  return "done";
}
function second() {
  return third();
}
function first() {
  return second();
}
first();

While first() runs, the stack holds first → second → third. Crucially, nothing else can run until the stack drains. A synchronous loop that takes two seconds blocks everything — rendering, clicks, timers — for those two seconds.

Where async work goes

Functions like setTimeout, fetch, and DOM event listeners are not part of the JavaScript language. They are provided by the host environment — Web APIs in the browser, libuv in Node.js. When you call them, the work is handed off to the host, which runs it outside the call stack. When that work finishes, the host doesn’t call your code directly; it places your callback into a queue and waits for the stack to be empty.

There are two queues, and the difference between them is the heart of this topic.

QueueAlso calledFed byPriority
Microtask queueJob queuePromise.then/catch/finally, await, queueMicrotask, MutationObserverHigher
Macrotask queueTask / callback queuesetTimeout, setInterval, I/O, UI events, setImmediate (Node)Lower

How the loop actually turns

The event loop runs a simple, repeating algorithm:

  1. Run all synchronous code until the call stack is empty.
  2. Drain the entire microtask queue — including any microtasks scheduled while draining.
  3. Render any pending UI updates (in browsers).
  4. Take one task from the macrotask queue and run it.
  5. Go back to step 2.

The asymmetry is the key insight: microtasks are emptied completely after every single task, but tasks are processed one at a time. That is why promises always beat timers — promise callbacks are microtasks, and the whole microtask queue is flushed before the next timer task ever gets a turn.

                ┌─────────────────────┐
                │     Call Stack      │  ← runs one frame at a time
                └──────────┬──────────┘
                           │ empty?

   ┌──────────────────────────────────────────┐
   │   Microtask queue (drain ALL)             │  ← Promise.then, await
   └──────────────────────────────────────────┘

                           ▼ (one only)
   ┌──────────────────────────────────────────┐
   │   Macrotask queue (take ONE per turn)     │  ← setTimeout, events
   └──────────────────────────────────────────┘

                           └──── loop back ────┘

An ordering example, step by step

This snippet is the classic interview question. Predict the output before reading on.

console.log("1: script start");

setTimeout(() => console.log("2: setTimeout"), 0);

Promise.resolve()
  .then(() => console.log("3: promise 1"))
  .then(() => console.log("4: promise 2"));

console.log("5: script end");

Output:

1: script start
5: script end
3: promise 1
4: promise 2
2: setTimeout

Here is exactly what happens:

  1. console.log("1: …") runs synchronously → prints 1.
  2. setTimeout(…, 0) hands its callback to the host; after 0 ms the host puts it in the macrotask queue.
  3. Promise.resolve().then(…) schedules a microtask.
  4. console.log("5: …") runs synchronously → prints 5.
  5. The stack is now empty. The loop drains microtasks: 3 prints, and its .then schedules another microtask, which prints 4. The microtask queue is now empty.
  6. The loop takes one macrotask: the timer callback prints 2.

The “0” in setTimeout(fn, 0) is a minimum delay, not a promise of immediacy. Even with zero delay, the callback waits for the current task and all microtasks to finish — and browsers clamp nested timeouts to a 4 ms floor.

await is just microtasks in disguise

async/await is built on promises, so everything after an await runs as a microtask. The code reads top-to-bottom, but execution pauses at await and resumes in the microtask queue.

async function run() {
  console.log("A");
  await null; // pauses; resumes as a microtask
  console.log("B");
}
run();
console.log("C");

Output:

A
C
B

console.log("A") runs immediately, then run() yields at await, letting C print synchronously before B resumes.

A tight loop that keeps scheduling microtasks (e.g. recursive Promise.resolve().then) can starve the macrotask queue, blocking rendering and timers indefinitely. When you need to yield to the browser, schedule a macrotask with setTimeout instead.

Browser vs Node.js

The model is the same, but Node.js splits the macrotask phase into ordered stages (timers, pending callbacks, poll, check, close) via libuv. Node also adds process.nextTick, which runs before the regular microtask (promise) queue, and setImmediate, which fires in the “check” phase. Browsers have neither; for portable code, stick to promises and queueMicrotask.

Best practices

  • Treat the main thread as precious — never run long synchronous loops; chunk heavy work with setTimeout or offload it to a Web Worker.
  • Reach for queueMicrotask (not setTimeout(…, 0)) when you specifically need to defer work until after the current task but before the next render.
  • Remember promise callbacks always run before timer callbacks; rely on this ordering deliberately rather than by accident.
  • Avoid unbounded microtask recursion — it can starve rendering and I/O; yield with a macrotask when you need the UI to update.
  • Don’t depend on the exact delay of setTimeout; it is a floor, subject to clamping and queue pressure.
  • In Node.js, understand that process.nextTick outranks promises — use it sparingly to avoid surprising ordering.
Last updated June 1, 2026
Was this helpful?