All posts
Article
JavaScriptAsyncInterviews

The Event Loop, Demystified: how JavaScript decides what runs next

Sep 22, 2026 7 min readby Tuan Nguyen

"Predict the output of this snippet" is probably the most common JavaScript interview question there is. It's also the one that trips up experienced developers — not because the event loop is complicated, but because it's almost never taught as a system. You learn that setTimeout is "async", that Promises are "thenable", and then you're left guessing when both appear in the same script.

Here's the fundamental reality, up front: JavaScript is single-threaded. One call stack, one thread, one line of code executing at any instant. Everything else — the event loop, the queues — is machinery for deciding what runs next once the current line finishes. Once you see the three buckets the work falls into, output-order questions stop being guessing games.

The three buckets

Every unit of scheduled work in a JavaScript runtime lives in exactly one of three places:

┌─────────────────────────────────────────────────────────┐
│  1. THE CALL STACK          2. MICROTASK QUEUE          │
│     Runs NOW                   Promise callbacks           │
│     Synchronous code           .then() / .catch()          │
│     Blocks everything          code after await            │
│                                queueMicrotask()            │
│                                ── HIGH priority ──         │
│                                                             │
│                             3. MACROTASK QUEUE (tasks)      │
│                                setTimeout / setInterval     │
│                                I/O, DOM events, UI render   │
│                                ── LOW priority ──           │
└─────────────────────────────────────────────────────────┘

1. Synchronous code — the Call Stack

The call stack is where code actually executes. Calling a function pushes a frame; returning pops it. The runtime works through the stack line-by-line, in written order, and it is completely blocking: nothing else in the entire process can run until the stack is empty. That's what "single-threaded" means in practice.

2. Microtasks — the Promise queue

Microtasks are the work JavaScript itself schedules: Promise callbacks (.then(), .catch(), .finally()), everything after an await, and anything queued with queueMicrotask().

The rule that matters: as soon as the call stack empties, the microtask queue is drained completely — every microtask, including any new ones spawned while draining — before the runtime even looks at the macrotask queue. Microtasks always win.

3. Macrotasks — the Task queue

Macrotasks are work the host environment schedules: setTimeout, setInterval, I/O callbacks, DOM events. The event loop takes one task per turn, then re-checks the microtask queue first.

And here's the misconception worth busting: setTimeout(fn, 0) does not mean "run immediately." It means "enqueue fn as a macrotask after a minimum delay of 0ms." The timer can't fire until the current stack finishes, every microtask drains, and the event loop picks the task up. Zero is a floor, not a fast-pass.

The walkthrough

Let's put all three buckets in one script and trace it. Try predicting the output before reading on:

setTimeout(() => console.log("timeout 1"), 0);
Promise.resolve().then(() => console.log("promise 1"));
(async () => {
    console.log("async start");
    await null;
    console.log("async end");
})();
console.log("end");

If you said async start → end → promise 1 → async end → timeout 1, you can skip to the takeaways. For everyone else — and this is most of us, the first time — here's the exact trace.

Step 1 — the setTimeout line

Call Stack:   [script]
Microtasks:   (empty)
Macrotasks:   [timeout 1  ← setTimeout schedules the callback]

The engine calls setTimeout, but nothing runs yet — the host registers the callback as a macrotask with a 0ms minimum delay. The call returns immediately. Stack continues.

Step 2 — the Promise line

Call Stack:   [script]
Microtasks:   [promise 1  ← .then() queues the callback]
Macrotasks:   [timeout 1]

Promise.resolve() creates an already-resolved promise, but .then() never runs its callback synchronously — by spec, it's queued as a microtask. Note it lands behind nothing in its own queue: it's first in line there, but microtasks still won't run until the stack empties.

Step 3 — the async IIFE is invoked

Call Stack:   [script → async fn]
Microtasks:   [promise 1]
Macrotasks:   [timeout 1]

Console so far: (nothing yet)

The async function is called, and here's the part people miss: an async function runs synchronously until its first await. So console.log("async start") executes right now, pushed onto the stack like any ordinary function call:

Console so far: async start

Step 4 — await suspends the function

Call Stack:   [script]
Microtasks:   [promise 1, async end  ← the continuation resumes here]
Macrotasks:   [timeout 1]

await null does two things. First, it wraps null in a resolved promise. Second, it suspends the async function: the frame returns to the caller immediately, and everything after the await — the rest of the function body — is scheduled as a microtask continuation, exactly as if you'd written Promise.resolve(null).then(() => { ... rest ... }).

Under the hood, async/await is promises: the compiler desugars your function into a chain of .then() continuations. That's why code after await behaves identically to code inside a .then().

Because the stack frame popped, the script itself keeps running — the async function did not block anything.

Step 5 — the last synchronous line

Call Stack:   [script]
Microtasks:   [promise 1, async end]
Macrotasks:   [timeout 1]

Console so far: async start, end

console.log("end") runs, the script's frame pops, and — for the first time — the call stack is empty.

Step 6 — drain the microtask queue

Now the event loop does its highest-priority job: drain every microtask before touching the macrotask queue.

Microtask 1 → promise 1 runs
Microtask 2 → async end runs        (the await continuation)
Console so far: async start, end, promise 1, async end

Two things to notice:

  • promise 1 beats timeout 1 even though setTimeout(..., 0) was registered first. The timer was first in line for the macrotask queue, but the event loop never got there until the microtask queue was fully drained.
  • async end beats timeout 1 for the same reason — it was queued as a microtask back in Step 4.

Step 7 — one macrotask, and we're done

Macrotask 1 → timeout 1 runs
Console so far: async start, end, promise 1, async end, timeout 1

Full output:

async start
end
promise 1
async end
timeout 1

The cheat sheet

| | Synchronous | Microtasks | Macrotasks | |---|---|---|---| | What it is | Code on the call stack | JS-scheduled continuations | Host-environment work | | Lives in | Call Stack | Microtask Queue | Task Queue | | Priority | Immediate (blocking) | High — runs before any task | Low — runs after all microtasks | | Drain strategy | Runs to completion, blocks everything | Drains the entire queue, including new arrivals | One task per event-loop turn | | Common examples | Top-level code, function calls | .then/.catch/.finally, code after await, queueMicrotask(), MutationObserver | setTimeout, setInterval, I/O, DOM events, UI rendering |

Key takeaways

1. Async functions are synchronous until the first await. The function body up to await executes immediately on the stack, in written order. Only the continuation gets queued. If your "mystery ordering" bug involves an async function that seems to run out of order, check whether the surprising part is before the first await.

2. Microtasks always beat macrotasks — always. A setTimeout(..., 0) registered a second ago still runs after a Promise scheduled now. When you need something to run "next turn" no matter what, that's a task; when you need it to run "after this stack, before any rendering or timers," that's a microtask.

3. await X is sugar for Promise.resolve(X).then(continuation). There is no third scheduling mechanism. Once that clicks, any async/await snippet can be mentally rewritten as Promise chains — and then traced with the queue rules.

4. setTimeout(fn, 0) means "as late as possible, minimum 0ms." The delay is a floor enforced by the host, and the callback still waits for the current stack to finish, every microtask to drain, and the event loop's next turn. Never use it to mean "run next" — that's queueMicrotask(fn).

If you can internalize one sentence, make it this: run the stack to completion, drain all microtasks, then take one macrotask — and repeat. That single loop explains every "predict the output" question you'll ever be asked.