Case 6/13

Prefer reading? The full explanation

How Rejections Run

throw · catch · finally · Browser

Before this

How Promise Chains Run — when a chained handler queues.

After this you can explain

What throw becomes inside an async function, when catch and finally actually run, and what makes a rejection "unhandled".

async function load() {
  throw new Error('boom');
}

load()
  .catch((err) => console.log('1: caught', err.message))
  .finally(() => console.log('2: finally'));

console.log('3: sync end');

Output: 3: sync end → 1: caught boom → 2: finally

Why does 3 print before the catch?

The output is 3 → 1 → 2. The throw on line 2 executes synchronously during the call to load() — but inside an async function, a throw does not unwind into the caller. It settles the promise load() returns as rejected.

From that point the error is data, not control flow. The .catch handler attaches to an already-rejected promise, so it queues as a microtask immediately — and like every microtask, it waits for the synchronous script to finish. That is why 3: sync end prints first.

Why finally runs last

.finally() is chained onto the promise .catch() returns, and that promise only settles when the catch handler has run. Chains advance one link per microtask turn, whether the links are then, catch or finally.

One detail worth keeping: the catch handler returns undefined rather than re-throwing, so its promise fulfils. A handled rejection converts the chain back to the success path — the second experiment shows a value flowing out of a catch into a later then.

What "unhandled" actually means

A rejection is not unhandled because nobody has caught it yet — handlers queue as microtasks, so there is always a gap. The runtime makes the call later: after the microtask checkpoint, it checks which rejected promises still have no handler attached, and reports those.

This is host behaviour, not a queue you can see. Browsers fire the unhandledrejection event (and rejectionhandled if a handler is attached late); Node emits unhandledRejection on process and, by default in modern versions, exits the process. The first experiment traces this path — note the step where the host performs its check, distinct from any queue draining.

The habits that follow

End every promise chain that leaves your function with a .catch, or await it inside try/catch — attaching the handler in the same turn is what keeps the rejection out of unhandled territory.

And treat window.addEventListener("unhandledrejection", ...) as a last-resort telemetry hook, not an error strategy: by the time it fires, the context that produced the error is gone.

Reading about it is one thing. Watching it run is another.

↑ Run it step by step

In the wild

Where this rule bites real code

The fire-and-forget that took down the process

Symptom
A background task — saveDraft() called without await — works for months, then one bad input rejects and your Node service exits with ERR_UNHANDLED_REJECTION.
Why
A rejected promise with no handler attached by the end of the checkpoint is reported to the host. Node's default since v15 is to crash the process.
Fix
Never let a promise escape without an owner: void saveDraft().catch(report). If a function is intentionally fire-and-forget, the .catch IS the contract.

The catch that silently ate the error

Symptom
Data is missing downstream, but there is no error anywhere — logs are clean.
Why
A .catch(() => {}) or a catch that only logs converts the chain back to fulfilment. Everything after it runs as if the operation succeeded, with undefined where data should be.
Fix
Inside catch, either genuinely recover (return a usable fallback) or re-throw after logging. A catch that does neither is where errors go to disappear.

Environment

Browser

CI verified

Node.js v22.22.3

unhandledrejection experiment bridges the browser event to process.on("unhandledRejection")

Last reviewed

29 July 2026

The animation on this page is a teaching model, not a diagram of browser internals. Real engines use several task queues, optimise aggressively, and do plenty of work these panels do not show. What is guaranteed is the observable ordering: the stated output is produced by actually executing the code in CI, on the runtime named above. Browser-specific behaviour follows the HTML Standard and is not executed in a real browser by CI. How this is verified →