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.
// 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.
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.