Node.js

Node.js — An unhandled promise rejection crashes the process

Written and reviewed by Sahil Srivastav

PromisesAsync ownershipProcess lifecycle
Error: upstream unavailable

What this error actually means

A promise rejected without a rejection handler attached within the relevant event-loop turn. Under Node’s throw policy, an unhandled rejection can become an uncaught exception and terminate the process. The displayed error often remains the original Error, such as the minimal reproduction above; it need not contain the phrase UnhandledPromiseRejection.

A surrounding try/catch only catches a rejected promise when that promise is awaited inside the try, or when its rejection flows through the returned chain. Calling an async function starts work and returns a promise immediately. If the caller discards that promise, its later failure has no connection to the caller’s synchronous exception boundary.

The diagnostic question is who owns completion. Request work should finish or fail through the request handler. Background work needs its own explicit supervisor, error reporting and retry decision. A global listener observes a lost failure but cannot reconstruct the transaction, HTTP response or business operation that should have handled it.

Causes, most common first

  1. 1A promise is started and discarded. A handler invokes sendReceipt() without awaiting or returning it. Prefixing the expression with void may satisfy a lint rule but does not attach a rejection handler. The same mistake appears in async forEach callbacks, whose returned promises are not collected by forEach.
  2. 2A callback API does not observe async callback failures. An EventEmitter listener or timer callback is declared async, but its caller does not await the returned promise. Synchronous exceptions and rejected promises take different paths. Check the API contract rather than assuming every callback consumer understands promises.
  3. 3A derived promise is left unhandled. then and finally return new promises. Attaching cleanup with task.finally(cleanup) and ignoring its result can create a second unhandled rejection even if task itself has a catch. A catch handler that throws or returns a rejected promise also transfers failure to a new chain.
  4. 4The handler is attached after the rejection is already reported. A promise is created early, stored and awaited much later. If it rejects before the eventual await, Node can report it as unhandled first. Creating work only when it has an owner avoids this timing gap and makes cancellation easier to reason about.

When you see it

  • The HTTP response succeeds, then the process exits when a background task rejects
  • The stack names a library call even though request middleware has an error handler
  • An upgrade or deployment flag change turns previous warnings into restarts
  • Failures occur only on timeout or rejection paths, while happy-path tests pass

How to diagnose it

Step 1

Record the actual launch policy

Inspect runtime version, executable arguments and NODE_OPTIONS from the affected process. Historical Node defaults differ, and a wrapper or test runner may install listeners. Reproduce using explicit strict handling so the test does not depend on a developer machine’s default.

node --unhandled-rejections=strict --trace-uncaught server.js

Step 2

Produce a controlled rejection

This intentionally fails and demonstrates that the displayed error can simply be the original rejection reason. Compare its shape with the incident before searching only for a particular warning string.

node --unhandled-rejections=strict -e "Promise.reject(new Error('upstream unavailable'))"

Step 3

Trace the promise chain to its final owner

Inspect every then, catch and finally from the failing call to the request boundary. Look especially at callbacks passed to forEach, timers and event emitters. Ask whether the promise returned by each transformation is awaited, returned or explicitly supervised.

Step 4

Force the failure after the caller returns

Make the dependency reject on a later tick in a staging test. Assert both the expected request outcome and completion of background work. An immediate synchronous throw tests a different path and can conceal the missing await.

The fix

Await request-scoped operations inside the handler’s error boundary. If several tasks may run concurrently, construct a bounded set and await Promise.all or deliberately inspect every result from Promise.allSettled. Merely collecting promises without awaiting the aggregate leaves the original ownership problem.

For intentionally detached work, attach a terminal rejection handler immediately and make it report the task identity and outcome. Prefer a durable queue when the operation must survive process restarts; an in-memory catch does not provide delivery guarantees.

Keep cleanup in an awaited try/finally or return the promise produced by finally. Preserve the original exception if cleanup also fails, and ensure a logging failure does not create another rejected chain.

Use process-level monitoring as a last-resort incident signal. Do not swallow every rejection globally and continue as though requests succeeded. Establish whether the process has trustworthy state, and let the normal supervisor recover a failed instance when that cannot be guaranteed.

async function createOrder(req, res, next) {
  try {
    const order = await saveOrder(req.body);
    await enqueueReceipt(order.id);
    res.status(201).json(order);
  } catch (error) {
    next(error);
  }
}

// A deliberately best-effort task still has a rejection owner.
function refreshInBackground(key) {
  void refreshCache(key).catch(error => {
    console.error("cache refresh failed", { key, error });
  });
}

How to stop it coming back

  • Run rejection-path tests under an explicit unhandled rejection policy
  • Require an owner for every async callback and promise returned by finally
  • Separate best-effort background work from business-critical durable jobs
  • Record task IDs and request IDs without logging sensitive payloads

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

FAQ

Why did try/catch not catch my async call?

It surrounds only the act of calling unless you await the returned promise within the try. A rejection occurring after the function returns cannot jump back into a completed synchronous catch block.

Is adding an unhandledRejection listener the fix?

It changes observation and, depending on policy, termination behaviour. It does not return an error to the original caller or undo partial work. Repair the lost promise chain first; retain monitoring only for unexpected escapes.

Does Promise.all cancel the other operations on failure?

No. Its rejection reports failure of the aggregate, while other started operations may continue. Pass cancellation signals where supported and design side effects so that partial completion has an explicit outcome.

Related

Other errors engineers hit next to this one

Full error and symptom index →