Monitoring unhandled promise rejections without crashing

Since Node.js 15 an unhandled rejection terminates the process by default. The fix is not to suppress that — it is to observe the rejection, report it, and then let the process restart.

What Node.js actually does

A promise rejection with no attached handler raises the process-level `unhandledRejection` event. Since Node.js 15 the default mode is `throw`: if nothing listens, Node raises it as an uncaught exception and the process exits with a non-zero code.

That default is correct and you should keep it. A rejection you did not handle means your program reached a state it was not written for; continuing to serve traffic from that state is how a small bug becomes corrupted data.

The mistake: a listener that swallows

The most common fix found in the wild registers a listener that logs and returns. Attaching any listener replaces the default `throw` behaviour, so the process now survives — which is precisely the problem. The bug stops being visible and starts being permanent.

If you attach a listener, report the rejection and then exit deliberately. Let your supervisor — systemd, Docker, Kubernetes, pm2 — restart the process in a known-good state.

bad-vs-good.js
// DON'T: this keeps a broken process alive and hides the bug.
process.on('unhandledRejection', (reason) => {
  console.error('unhandled rejection', reason);
});

// DO: report it, flush, then let the supervisor restart you.
process.on('unhandledRejection', (reason) => {
  const error = reason instanceof Error
    ? reason
    : new Error(`Non-error rejection: ${String(reason)}`);

  reportSomewhere(error);

  // Give the reporter a moment to flush, then exit non-zero.
  setTimeout(() => process.exit(1), 1000).unref();
});

Rejections are often not Error objects

`Promise.reject("something failed")` rejects with a string. A rejection value can be any JavaScript value, including `undefined`, which is what produces the famously useless log line `unhandledRejection undefined`.

Normalise before reporting. Without a real Error you have no stack, and without a stack you cannot tell which of forty call sites produced it — so synthesise one as early as possible.

Capturing with request context

A stack trace tells you where a rejection was thrown but not what was being served at the time. Attaching the request — route, method, user, body — is usually what turns a confusing report into an obvious one.

VantaTrace handles both parts: `initGlobalHandlers()` installs normalising handlers for `unhandledRejection` and `uncaughtException`, and because the SDK tracks the active request in AsyncLocalStorage, a rejection raised during a request is reported with that request attached. The capture is buffered and flushed off the request path, so reporting never delays the response.

server.js
import express from 'express';
import { VantaTrace } from '@vantatrace/sdk';

const vantaTrace = new VantaTrace({
  apiKey: process.env.VANTATRACE_API_KEY,
});

// Normalises non-Error rejections, reports with context, then exits.
vantaTrace.initGlobalHandlers();

const app = express();
app.use(vantaTrace.requestHandler());

app.get('/orders/:id', async (req, res) => {
  const order = await loadOrder(req.params.id); // a rejection here is captured
  res.json(order);
});

app.use(vantaTrace.errorHandler());
app.listen(3000);

How to verify it works

Do not trust this by reading it. Add a temporary route that rejects without a handler, hit it once, and confirm two things: the error appears in your monitoring with the route attached, and the process exits with a non-zero code.

Then check the harder case — a rejection raised outside any request, for example in a `setInterval` callback. It should still be reported, just without request context.

Related guides