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