Skip to content
Preptima
hardConceptCodingMidSeniorStaff

A promise rejects and nothing is awaiting it. What does Node do?

Node asks whether a rejected promise has a handler at the end of the tick, once microtasks have drained. If none has appeared, it emits unhandledRejection, and since Node 15 the default mode raises that as an uncaught exception and kills the process.

4 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Whether the candidate separates an unhandled rejection from an uncaught exception rather than treating both as an error that escaped
  • Does the answer place detection at the end of the tick, after microtasks drain, instead of at the moment of rejection
  • That the terminating default since Node 15 is stated, rather than the old warn-and-continue behaviour assumed
  • Whether they see that Promise.all attaches handlers eagerly while awaiting an array of promises in a loop does not
  • Does the candidate treat a global handler that logs and carries on as a fix or as a liability

Answer

Rejection is a state, not yet an error

A rejected promise is not an error condition on its own. Rejection is a state change on an object, and V8 records both the state and whether any handler has been attached to it. Node then asks "does this rejected promise have a handler?" — but not at the moment of rejection. It asks at the end of the tick, after the microtask queue has been drained, because a handler could legitimately be attached by a microtask that has not run yet. If one has appeared by then, nothing happens at all. If not, Node emits unhandledRejection.

That gap between rejecting and being judged is where every surprise in this area lives. It is why the same rejected promise can be fatal in one code shape and harmless in another that looks nearly identical.

Since Node 15 the default is to terminate

The behaviour is controlled by --unhandled-rejections. Under the default throw mode, Node emits unhandledRejection, and if you have registered no listener for it, it raises the rejection reason as an uncaught exception, which prints the stack and exits with a non-zero code. warn mode only prints a warning and keeps running, and none suppresses the report entirely.

Node 15 changed that default from warning to throwing. This matters in an interview because a great deal of received wisdom, and a great deal of code, was written when a stray rejection printed a deprecation-style warning and the process shrugged and carried on. On a current runtime the same code takes the container down, so a candidate who says "you get a warning" is describing a runtime that has not shipped in years.

An await in a loop is not the same as Promise.all

Both of these start every task immediately. Only one of them survives a failure in the second task.

const tasks = [fetchA(), fetchB(), fetchC()]; // all three are already running

// Safe: Promise.all calls .then on each promise synchronously, right now,
// so all three have handlers before this tick ever ends.
try {
  const all = await Promise.all(tasks);
} catch (err) {
  /* one rejection, and every promise had a handler */
}

// Unsafe: during the first tick, only tasks[0] has a handler.
const out = [];
for (const t of tasks) {
  out.push(await t); // <-- tasks[1] rejects while we are parked on tasks[0]
}

Trace the unsafe version with fetchA taking 500 ms and fetchB rejecting after 50 ms. At 50 ms, tasks[1] is rejected and nothing has ever called .then on it, because the loop has not reached its second iteration. Microtasks drain, Node finds a rejected promise with no handler, and the process dies at 50 ms — before the await that would have handled it was ever reached. The sequential-looking loop reads as the more cautious code and is the one that crashes.

The fix is to attach handlers when you create the promises rather than when you consume them: Promise.allSettled, or Promise.all inside a try/catch, or an explicit .catch() on each promise at creation time if you genuinely want to inspect the failures later.

Promises nobody keeps

The other common source is an async function whose returned promise is discarded by the code that called it. emitter.on('data', async (chunk) => { ... }) hands EventEmitter a function that returns a promise EventEmitter has no concept of, so a throw inside it becomes a rejection with no owner. array.forEach(async (x) => ...) does the same, as does setTimeout(async () => ...), and so does an async route handler under Express 4, which ignores whatever a handler returns.

Note the asymmetry with a synchronous callback. If a synchronous listener throws, the exception propagates out of emit and becomes an uncaughtException carrying a stack through the emitting frame. The async version becomes an unhandledRejection reported from a bare microtask, so the trace often shows nothing above the failing line and nothing about which request caused it. That attribution problem, rather than the crash itself, is what makes these expensive, and it is why the no-floating-promises rule in @typescript-eslint earns its place in a service's lint configuration.

Why a global handler that logs and continues makes things worse

The tempting mitigation is process.on('unhandledRejection', (err) => logger.error(err)). That registers a listener, which stops throw mode from raising an uncaught exception, so the process keeps running. What it does not do is tell you what state the process is now in. Something started work, the work failed, and the code that was going to reconcile the outcome never ran: a transaction left open, a lock never released, a semaphore permit not returned, a queue message neither acknowledged nor rejected. You have converted a loud crash into a process that serves traffic while quietly diverging from its own invariants, which is strictly harder to debug and strictly worse for your data.

The defensible shape is a handler that logs the rejection with everything you can attach to it, flushes the logger, and then exits, letting the supervisor start a clean process. That is not fatalism about reliability; it is the same reasoning behind crash-only design, that a fresh process has known state and a limping one does not. Pair it with the lint rule so that the handler fires because of genuine surprises rather than because somebody forgot an await.

A rejection becomes fatal based on whether a handler exists when the microtask queue drains, which is why attaching handlers at creation time and awaiting later is safe while creating promises and awaiting them one at a time is not.

Likely follow-ups

  • Which async operations lose their request context by the time a rejection reaches a global handler, and how would you get the correlation identifier back?
  • After an unhandledRejection versus after an uncaughtException, what process state can you still trust?
  • How would you make a floating promise a build failure rather than a runtime one?
  • When does attaching a .catch a tick late still count as handled, and what does Node print when it does?

Related questions

Further reading

promiseserror-handlingasyncmicrotasksnodejs