skip to content

If a promise rejects and you attach a .catch() handler to it inside a setTimeout callback, is the rejection still reported as unhandled? Explain the timing rule the runtime uses.

level: middleimportance: should knowfreq 50%

answer

  1. it is a deadline, not an eventual check
  2. the turn has to end first
  3. timers start a new turn
  4. a second event fires when you attach late
  5. Node may be gone before the timer runs

basics

~20 s

Yes. Runtimes decide a rejection is unhandled once the current turn's queued jobs have finished running, so a handler attached in a later timer callback arrives too late: the unhandled rejection is reported first, then a rejectionhandled notification follows.

solid answer

~50 s

The check is not "has a handler ever been attached" but "was one attached by the end of this turn". After the currently running script or callback finishes and the promise jobs it queued have all run, the host looks at every promise that rejected during that turn with no handler and reports them. A `.catch()` attached synchronously, or from a `.then()` callback in the same turn, is in time. A `.catch()` attached from a `setTimeout` callback runs in a later turn, so the report has already fired. Attaching it afterwards does not undo the report — it produces a second notification, `rejectionhandled` in the browser or `rejectionHandled` in Node, which exists precisely so tooling can retract a warning it already showed. In Node's default mode the process may already be gone before the timer ever runs.

code

javascript · 15 lines
javascript
const inTime = Promise.reject(new Error('a'));
inTime.catch(() => console.log('a handled — no report'));

const tooLate = Promise.reject(new Error('b'));
setTimeout(() => {
  tooLate.catch(() => console.log('b handled — but reported already'));
}, 0);

addEventListener('unhandledrejection', (e) => {
  console.log('unhandled:', e.reason.message); // b
  e.preventDefault();
});
addEventListener('rejectionhandled', (e) => {
  console.log('later handled:', e.reason.message); // b
});

go deeper

for a junior

Remember the shape of the rule: a rejection handler has to be attached in the same turn, and a setTimeout callback is already a different turn. Attaching it later is too late.

for a middle

Explain the deadline precisely — the check happens once the current turn and the jobs it queued have finished — and name the rejectionhandled / rejectionHandled follow-up event and what it is for.

for a senior

Connect the rule to real failure modes you have seen: module-level promises consumed after a delay, awaiting pre-started promises in sequence, and the fact that a Node process may exit before the late handler ever runs.

for a principal

Argue the design tradeoff — why the deadline is set at the turn boundary rather than immediately or never — and what codebase conventions keep long-lived promises claimed at creation without turning empty catch handlers into an error-swallowing habit.

## The rule: end of turn, not end of time A promise knows whether any rejection handler has been attached to it. What is less obvious is *when* the runtime asks. It does not ask at the instant of rejection — that would break the extremely common pattern of creating a promise and attaching handlers on the next line. It also does not wait indefinitely. It asks once the current turn has fully unwound: the running script or callback has returned, and every job queued during it has run. At that point the host is handed the list of promises that rejected during the turn and still have no handler, and reports them: `unhandledrejection` on the browser's global object, or Node's `unhandledRejection` / default `throw` behaviour. So the practical boundary is: **anything you do before yielding to a timer, an I/O callback, or an event is in time; anything you do after is late.** ## What is in time ```js const p = Promise.reject(new Error('boom')); p.catch(err => console.log('handled:', err.message)); // in time ``` Even though `p` is already rejected when `.catch()` runs, no report has happened yet — the turn has not ended. The same holds one level deeper: ```js const p = Promise.reject(new Error('boom')); Promise.resolve().then(() => p.catch(handle)); // still in time ``` That callback runs as part of draining the current turn's queued jobs, before the host does its check. ## What is late ```js const p = Promise.reject(new Error('boom')); setTimeout(() => p.catch(handle), 0); // too late ``` `setTimeout` schedules a *new* turn. The current turn ends first, the check runs, and the rejection is reported. Later, when the timer fires and `.catch()` is finally attached, the promise transitions from "reported unhandled" back to "handled" and a second notification fires: - browser: a `rejectionhandled` event on the global object, - Node: a `rejectionHandled` event on `process`. These exist for exactly one purpose: a debugger or logger that already surfaced a warning can now retract or downgrade it. They are not a repair mechanism — the first report already happened, and any side effect it caused (a logged error, a triggered alert, a crashed process) has already occurred. In Node's default configuration this is even starker: the unhandled rejection is raised as an uncaught exception and the process exits, so the timer that would have attached the handler never runs at all. ## Where this bites in real code The timing rule is what makes several ordinary-looking patterns fail. **Storing a promise now, consuming it later.** A module-level cache that kicks off a request at import time and expects consumers to `await` it after some event will report an unhandled rejection if the request fails before the first consumer arrives. The fix is to attach a handler at creation time — even a no-op that just re-exposes the failure through the cached promise — so the promise is never handler-less across a turn boundary. ```js const config = loadConfig(); config.catch(() => {}); // claim it now export const getConfig = () => config; // callers still see the rejection ``` That `catch(() => {})` looks like swallowing an error, and in isolation it would be — but the promise it returns is discarded, while `config` itself still rejects for every real consumer that awaits it. The empty handler exists only to mark the original as claimed. **Retrying or racing after a delay.** Anything of the shape "start the work, wait for a tick, then decide who handles the failure" is on the wrong side of the line. **Awaiting several already-started promises in sequence.** Each promise that has not yet been awaited is handler-less; if one of them rejects during a turn in which you are parked on a different `await`, it is reported. ## How to reason about it in an interview State the rule in one sentence — "handlers must be attached before the turn ends, not merely eventually" — then name the observable consequence: a report you cannot take back, plus a `rejectionhandled` follow-up if you attach later. Mentioning that Node may already have exited shows you have thought about the server case rather than only the console case. The underlying design intent is worth stating too. The rule is deliberately permissive enough for the natural `const p = f(); p.catch(...)` idiom and deliberately strict enough that a promise cannot silently sit rejected for minutes before anyone notices. Anything that crosses a turn boundary is, from the runtime's point of view, indistinguishable from a promise nobody was ever going to handle.

  • What is the rejectionhandled event actually for, if the report already happened?
    It lets tooling retract. A console or error reporter that showed "unhandled rejection" when the deadline passed can downgrade or remove that entry once a handler appears, so a legitimately-late attachment does not leave a permanent false alarm. It repairs the *report*, not the consequences — anything the first notification triggered has already run.
  • Is `p.catch(() => {})` next to a long-lived promise a code smell?
    Only if the promise it returns is the one people consume. Attaching an empty handler to claim the original, while consumers still await the original, is a legitimate idiom for promises created long before they are used. It becomes a smell when the empty handler is on the path a real consumer takes, because then a genuine failure is silently converted into an ignored one.

saying these in an interview costs you the question

  • Thinks a handler attached any time later prevents the report
  • Believes rejectionhandled cancels or undoes the original report
  • Says the check happens immediately when the promise rejects
  • Assumes the timer callback still runs after Node exits
  • Confuses attaching a handler late with never attaching one

context