Microtasks

Promise handlers — the functions you pass to .then, .catch, and .finally — never run right where you attach them. They always run later, asynchronously.

Here’s the part that surprises people: even when a promise is already resolved, the code written on the lines after .then/.catch/.finally runs first. The handler waits.

A quick demo makes it concrete:

let promise = Promise.resolve();

promise.then(() => alert("handler ran"));

alert("sync done"); // this alert shows first

Run it and the order is sync done, then handler ran.

That feels backwards. The promise is resolved from the very first line — there’s nothing to wait for. So why does the .then handler fire only after the alert below it?

The answer is scheduling. A resolved promise doesn’t mean “run my handler now.” It means “run my handler as soon as the engine is free.” And the engine isn’t free until the current stretch of code finishes running.

The microtask queue

Asynchronous work needs an order. The language spec pins one down with an internal queue — the spec calls it PromiseJobs, and engine authors (V8, the engine in Chrome and Node.js) call it the microtask queue. They’re the same thing.

Two rules govern it, straight from the ECMAScript specification:

  • First in, first out. Jobs added earlier run earlier.
  • A job runs only when nothing else is executing. The engine won’t start a queued job while it’s in the middle of running your synchronous code.

Put plainly: when a promise settles, its .then/.catch/.finally handlers don’t run on the spot. They get appended to the microtask queue. Once the engine finishes the current script and has nothing else on the call stack, it pulls the first job off the queue and runs it, then the next, until the queue drains.

That’s exactly why sync done printed first. Attaching the .then only enqueued a job; the alert("code finished") line was still part of the running script, so it went first. Only after the script ended did the engine reach into the queue.

1. RUNNING SCRIPT (call stack)
promise.then(handler) → enqueue
alert(“sync done”)
MICROTASK QUEUE (FIFO)
handler → alert(“handler ran”)
head ↑ runs when stack is empty
stack emptiesengine drains queue“handler ran”
A settled promise enqueues its handler. Synchronous code keeps running until the call stack empties; only then does the engine drain the microtask queue in FIFO order.

Every promise handler travels through this queue — there’s no fast path that skips it.

That has a knock-on effect for chains. If you write several .then/.catch/.finally in a row, each one is scheduled separately. A handler runs, its result settles the next promise in the chain, that enqueues the following handler, and so on. Each link is its own microtask.

So what if you actually need sync done to come after handler ran?

Don’t fight the queue — join it. Put the later code in its own .then, chained after the first:

Promise.resolve()
  .then(() => alert("handler ran"))
  .then(() => alert("sync done"));

Now both alerts live in the queue, and FIFO order does the rest: handler ran first, sync done second.

Unhandled rejection, revisited

Back in Error handling with promises you met the unhandledrejection event. The microtask queue is the mechanism that decides when it fires.

An unhandled rejection is a promise that’s still in the rejected state with no handler attached, detected once the microtask queue has emptied.

When you do expect an error, you attach .catch and the rejection is handled the moment that handler runs — no event:

let promise = Promise.reject(new Error("Upload failed!"));
promise.catch(err => alert('handled'));

// doesn't run: the error was handled
window.addEventListener('unhandledrejection', event => alert(event.reason));

Drop the .catch and the story changes. The promise sits rejected, nothing consumes it, and once the queue drains the engine notices and fires the event:

let promise = Promise.reject(new Error("Upload failed!"));

// Upload failed!
window.addEventListener('unhandledrejection', event => alert(event.reason));

The genuinely tricky case is handling the error late:

let promise = Promise.reject(new Error("Upload failed!"));
setTimeout(() => promise.catch(err => alert('handled')), 1000);

// Error: Upload failed!
window.addEventListener('unhandledrejection', event => alert(event.reason));

Run this and you’ll see Upload failed! first, then handled a second later.

Without the queue in mind, that’s baffling — you did add a .catch, so why did unhandledrejection fire anyway? Because the check happens the instant the microtask queue empties, and at that point the .catch doesn’t exist yet. It’s scheduled inside a setTimeout, a full second away. The engine scans the pending promises, finds one rejected and unhandled, and fires the event.

The late .catch still runs when its timer comes due, and it still handles the rejection. But it arrives after the verdict has already been delivered, so it can’t take the event back.

t = 0
Promise.reject(…) — no handler yet
queue empty
engine checks → still rejected → fires unhandledrejection
t = 1000
setTimeout runs → .catch handles it (too late)
The unhandledrejection check runs at the moment the microtask queue empties. A .catch scheduled by setTimeout lands far later, on the timer queue — too late to prevent the event.

Summary

Promise handling is always asynchronous. Every .then/.catch/.finally handler is routed through the internal promise-jobs queue — the microtask queue — instead of running inline.

Because of that, those handlers always fire after the current synchronous code finishes. If you need a piece of code to run after a handler, chain it on with another .then rather than writing it below.

The same timing explains unhandledrejection: the engine checks for still-rejected promises once the microtask queue empties, which is why a .catch added by a later timer can’t stop the event.

In browsers and Node.js, the microtask queue is one gear in a larger machine that also handles timers, I/O, and rendering — the event loop and its macrotasks. Those aren’t specific to promises, so they get their own treatment in Event loop: microtasks and macrotasks.