• Home
  • Posts
  • Knowledge Base

© 2026 Kelly's Notes. All rights reserved.

Back to JavaScript

The Event Loop and Microtasks

JavaScript runs one thing at a time, and the event loop decides what runs next. Microtasks such as promise callbacks always jump ahead of timers and events, which explains most surprising ordering in async code.

Applies to: Promises: ES2015; async/await: ES2017; queueMicrotask(): Node.js 11+ (2018), all major browsers by early 2020 (Chrome 71, Safari 12.1, Firefox 69, Edge 79). Node's phased event loop differs from the browser model described here.

Intermediate12 min readLast reviewed on 2026-09-29

Before you start

  • Closures Explained

    A closure is a function that keeps access to the variables of the scope it was created in, even after that scope has finished running. It is what makes private state, factory functions, and most callback patterns possible.

    Read article →

Run this and predict the order the four lines print, before reading on.

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

The order is 1, 4, 3, 2 — not 1, 2, 3, 4, and not 1, 4, 2, 3 either. setTimeout with a delay of zero still finishes dead last, behind a promise callback that was scheduled after it.

JavaScript runs on one thread — it can only do one thing at a time — yet a page fetches data, runs timers, and responds to clicks without ever fully stopping. The event loop is the mechanism that decides what runs next, and getting that four-line puzzle right comes down to exactly one rule, which the rest of this article unpacks.

The restaurant analogy

Picture a restaurant with exactly one chef.

  • The chef cooks one dish at a time, start to finish. That's the call stack.
  • Orders waiting to be cooked sit in a queue on the pass. That's the task queue.
  • The oven, the timer, and the delivery driver all work on their own. Those are browser APIs — not the chef, and not part of JavaScript.
  • Between dishes, the chef always glances at a small priority slip holder first: corrections to the dish just served. That's the microtask queue.

The chef never abandons a dish halfway. If one dish takes twenty minutes of active work, every other order waits. That's what "blocking" means.

Where the analogy breaks: a human chef, even a disciplined one, would eventually get tired of the priority slips and serve the next full order anyway. The event loop has no such judgment call — it drains the microtask queue completely, no matter how many new slips arrive while it's looking, before it will ever glance at the task queue. That's not a minor detail. It's exactly why a microtask that keeps generating more microtasks can freeze a page forever, which the starvation trap below covers directly.

The moving parts

     ┌──────────────┐
     │  Call stack  │  ← one chef, one dish at a time
     └──────▲───────┘
            │  event loop puts the next callback here
            │  (only when the stack is empty)
   ┌────────┴──────────────────────────┐
   │                                   │
┌──┴───────────────┐        ┌──────────┴────────┐
│ Microtask queue  │        │    Task queue     │
│ promises,        │  ALL   │ setTimeout,       │
│ queueMicrotask,  │ before │ events, I/O,      │
│ MutationObserver │  ANY   │ setInterval       │
└──────────────────┘        └───────────────────┘
        ▲                            ▲
        └──── Web APIs / Node APIs ──┘
              timers, fetch, DOM events

The rule the whole article rests on: when the call stack empties, drain the entire microtask queue. Only then take one task from the task queue.

Reading the order

console.log('1');

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

Promise.resolve().then(() => console.log('3'));

console.log('4');

Walking through it:

  1. "1" runs immediately — synchronous code goes straight onto the stack.
  2. setTimeout hands its callback to the browser's timer. It is not on the stack, and it goes to the task queue once the timer fires.
  3. .then schedules a microtask — it goes to the microtask queue, not the task queue.
  4. "4" runs immediately, same as step 1.
  5. The stack is now empty. The event loop drains microtasks first → "3".
  6. Only now does a task run → "2".

setTimeout(fn, 0) does not mean "run now." It means "run after the current work and all pending microtasks."

Why microtasks come first

A promise represents a value that has already been decided. Callbacks reacting to that decision should not be delayed by unrelated work such as a timer that happened to be scheduled earlier.

Draining the whole microtask queue in one go means no task can slip between the links of a .then chain — no timer, no event, no render. It is not fully atomic, though: other microtasks can interleave. Two chains started side by side alternate link by link (a1, b1, a2, b2, …).

Promise.resolve()
  .then(() => console.log('a'))
  .then(() => console.log('b'));

setTimeout(() => console.log('timer'));

// a, b, timer

Each .then schedules the next microtask only when the previous one finishes, and the loop keeps draining until the queue is genuinely empty — including microtasks added while draining.

The starvation trap

Because the queue is drained completely, a microtask that schedules another microtask forever will freeze the page. Timers never get a turn, and neither does rendering.

function spin() {
  Promise.resolve().then(spin); // ❌ page is now frozen
}
spin();

The same loop with setTimeout is harmless — each iteration is a separate task, so the browser gets a chance to render and handle input in between.

function spin() {
  setTimeout(spin, 0); // ✅ slow, but the page stays responsive
}
spin();

This is the practical reason to know the difference. Anything recursive that runs on promises needs a task boundary somewhere.

Where rendering fits

In a browser, painting happens between tasks, never in the middle of one, and never during a microtask drain.

┌─ task ─┐ ┌ microtasks ┐ ┌─ render ─┐ ┌─ task ─┐ ┌ microtasks ┐ ...

Two consequences worth remembering:

  • A long synchronous loop blocks paint. The spinner you set just before it will never appear.
  • Splitting heavy work across setTimeout calls lets frames render in between. requestAnimationFrame is the precise tool when the work is visual, since it runs immediately before the next paint.

async/await is the same machinery

await is promise callbacks with different syntax. Everything after an await is effectively the body of a .then, so it runs as a microtask.

async function run() {
  console.log('A');
  await null; // yields here
  console.log('B'); // microtask
}

run();
console.log('C');

// A, C, B

The function runs synchronously until the first await, then returns control. "C" runs, the stack empties, and only then does "B" resume. This is also why await inside a loop serializes the work — each iteration waits for its awaited value to settle before the next one begins. It does not give the browser a chance to render, though: resuming after an await is a microtask, so no task runs between iterations unless the awaited promise itself waits on one (such as a setTimeout).

Side-by-side

MicrotaskTask (macrotask)
Examples.then, await, queueMicrotasksetTimeout, events, I/O
How many run per turnall of them, including newly added onesexactly one
Can starve the pageyesno
Rendering can happen beforenoyes

The rule of thumb

If ordering surprises you, ask two questions in order: has the call stack emptied yet, and is this a microtask or a task? Synchronous code finishes first, microtasks drain completely, then one task runs. In the browser that sequence is fixed, which is exactly why it works as a diagnostic — almost every "why did this log out of order" bug resolves against those three steps. (Node adds its own process.nextTick queue on top; see the notes below.)

Version and environment notes

  • Promises are ES2015; async/await is ES2017.

  • queueMicrotask() shipped in Node.js 11+ (October 2018). Browsers followed between late 2018 (Chrome 71) and early 2020 (Edge 79) — safe to assume in any current environment.

  • Node.js splits tasks into phases (timers, pending callbacks, poll, check, close callbacks) and adds two queues that don't exist in the browser model:

    Runs when
    process.nextTickafter the current operation; in CommonJS, before promise callbacks
    promise callbacksright after the nextTick queue drains, before the next phase
    setImmediatein the check phase, right after poll (I/O)

    The nextTick-first order holds in CommonJS only. An ES module's top-level code already runs as part of the microtask queue, so there promise callbacks run before nextTick ones. Node now marks process.nextTick as legacy and recommends queueMicrotask() instead.

    For interview purposes, the browser model above is almost always what's being asked about. Bring up Node's phases only if the question goes there specifically.

  • To verify behavior for a given runtime, log the order directly — this article's opening snippet is a fast way to check any environment.

Check yourself

1. What does this print?

console.log('start');

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

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'));

console.log('end');

2. What does this print?

async function log() {
  console.log('A');
  await Promise.resolve();
  console.log('B');
}

console.log('1');
log();
console.log('2');
Answers

1. start, end, promise 1, promise 2, timeout. The two synchronous lines run first. Then the microtask queue drains completely, including the second .then, which was only scheduled once the first one finished. Only after both microtasks run does the timer's task get a turn.

2. 1, A, 2, B. log() runs synchronously up to its first await, so "A" prints before console.log("2") ever runs. The rest of log resumes as a microtask, so "B" prints last, after the synchronous code following the call has finished.

Sources

  • MDN Web Docs — The event loop
  • MDN Web Docs — In depth: Microtasks and the JavaScript runtime environment
  • MDN Web Docs — await
  • MDN Web Docs — async function
  • MDN Web Docs — Window: requestAnimationFrame() method
  • MDN Web Docs — Window: requestIdleCallback() method
  • Node.js Docs — The Node.js Event Loop, Timers, and process.nextTick()
  • Node.js Docs — process: process.nextTick() and queueMicrotask()

Where people get stuck

My `setTimeout(fn, 0)` callback ran after other code and promise callbacks that were scheduled later than it.

Cause

A zero-delay timeout still goes through the task queue. Every pending microtask — including ones scheduled after the timeout — drains completely before any task gets a turn.

Fix

Don't use setTimeout to sequence something that must run before a promise callback. Use synchronous code, or restructure the logic so it doesn't depend on timer ordering.

My page froze completely, even though the recursive function I wrote uses Promises, which I thought were asynchronous.

Cause

A microtask that reschedules itself is drained immediately along with every other microtask, before the loop is allowed to move on. The browser never gets a turn to render or handle input.

Fix

Insert a real task boundary — schedule the next iteration with setTimeout instead of a promise — so the browser can breathe between iterations.

My code after `await` ran later than the code right after calling the async function, with no timer involved.

Cause

Everything after an await runs as a microtask. The async function only runs synchronously up to its first await — the caller's next line runs before that microtask does.

Fix

Treat everything after an await like the body of a .then(). It will never run before the code that follows the function call itself.

Common questions

What is the event loop?

JavaScript runs on a single thread, so it can only execute one thing at a time. Slow work such as timers, network requests, and DOM events is handed to the surrounding environment, which puts a callback in a queue when it finishes. The event loop is the mechanism that moves those callbacks onto the call stack, and it only does so once the stack is empty. The ordering rule that matters is: finish the current synchronous code, drain every pending microtask, then run exactly one task.

Why do promise callbacks run before setTimeout, even with a delay of zero?

They sit in different queues, and the microtask queue has absolute priority. A promise represents a value that has already been decided, so reacting to it should not be delayed by unrelated work that happened to be scheduled earlier. Draining the whole microtask queue in one pass also means no task — no timer, event, or render — can slip between the links of a then chain, though other microtasks queued at the same time can still interleave with it. setTimeout with zero delay does not mean run now; it means run after the current work and after everything in the microtask queue.

Can async code freeze the page?

Yes, in two ways. A long synchronous block holds the call stack, so nothing else runs and no frame is painted — a spinner set just before it will never appear. Less obviously, a microtask that schedules another microtask recursively will freeze the page too, because the loop drains the queue completely before doing anything else. Timers never get a turn. The same recursion built on setTimeout is safe, since each iteration is a separate task and the browser can render in between.

How do you keep a long-running computation from blocking the UI?

First ask whether it belongs on the main thread at all — anything genuinely heavy is better in a Web Worker, which runs on its own thread and keeps that work off the main thread where rendering happens. If it must stay on the main thread, split it into chunks separated by a task boundary so paint and input can happen in between; setTimeout works, and requestIdleCallback is better when the work can wait. For visual updates specifically, requestAnimationFrame runs immediately before the next paint. Awaiting a promise does not help, because microtasks run before rendering.

Keep reading

  • Promises and async/await

    A promise is a placeholder for a value that isn't ready yet, and async/await is a flatter syntax for the same machinery. Covers the three states, chaining, error handling, and running work in parallel instead of one at a time.

    Read article →