What you'll learn
Quick Answer
An unhandled rejection is a promise that rejected while nothing was attached to observe the failure. Modern Node treats this as a fatal error and terminates the process with exit code 1, where older versions only printed a warning. The usual causes are a missing await, an async callback handed to an API that ignores promises, and a promise created early but awaited later. Fix it at the source with await or .catch, and use process handlers only for logging before exit.
Why this now kills your server
Older Node versions printed UnhandledPromiseRejectionWarning along with a deprecation notice and carried on running. Since the default changed to throwing, the same code terminates the process with exit code 1 and a message like this:
node:internal/process/promises:392
new UnhandledPromiseRejection(reason);
^
UnhandledPromiseRejection: This error originated either by throwing
inside of an async function without a catch block, or by rejecting a
promise which was not handled with .catch(). The promise rejected with
the reason "nope". {
code: 'ERR_UNHANDLED_REJECTION'
}That wrapper is what you see when the rejection reason is not an Error. If you rejected with a proper Error object, as you should, Node prints that error's own message and stack instead and the wrapper never appears, so the give-away is the sudden exit rather than any particular wording. The exact internal line numbers also shift between Node releases; only the shape is stable.
This catches people upgrading an old project. Code that had been "working" for months was quietly leaking rejections the whole time; the runtime simply stopped tolerating it. If a service started crash-looping right after a Node upgrade with no code changes, this is the first thing to check.
The detection is timing based, which explains the strangest cases. Node checks for rejected promises with no handler when the microtask queue drains. Attach a .catch in the same tick and you are fine. Attach it later and the process has already decided:
const p = Promise.reject(new Error('boom'));
setTimeout(() => {
p.catch(() => {}); // too late, the process is already exiting
}, 0);Node emits a rejectionHandled event when a handler arrives late, which exists specifically so tooling can un-report a rejection it warned about earlier.
It is worth being precise about what "unhandled" means, because the word suggests the error was ignored when the truth is narrower. A promise has an internal list of reactions attached by then, catch, finally or await. If it settles into the rejected state while that list is empty, the failure has nowhere to travel. It is not that you handled it badly; it is that nobody was listening at all. Every fix in this article amounts to the same thing: make sure something is attached before the promise has a chance to reject.
You can restore the old behaviour with node --unhandled-rejections=warn. Resist it. The flag does not fix anything, it just moves a fatal bug back into the log file where nobody reads it, and the underlying operation still failed silently.
The missing await that makes try/catch useless
This is the most common cause, and it looks correct in review:
async function saveFee(studentId, amount) {
throw new Error('database unreachable');
}
async function handleSubmit() {
try {
saveFee(101, 2500); // no await
console.log('saved'); // prints, wrongly
} catch (err) {
console.error('failed', err); // never runs
}
}A try block guards the statements that run inside it. Without await, calling saveFee starts the work and immediately returns a pending promise. The try block finishes, the function returns, and only afterwards does the promise reject with nothing watching. You get a success log and a dead process.
There is a subtler version. Writing return somePromise() inside a try means the function has already returned by the time the promise settles, so the surrounding catch never sees the failure. return await somePromise() keeps the frame alive long enough for the catch to work. Outside a try block the two are almost equivalent, which is why the distinction is easy to forget.
Array iteration produces the same bug at scale:
// wrong: forEach ignores the returned promises entirely
students.forEach(async (s) => { await saveFee(s.id, s.amount); });
// sequential and catchable
for (const s of students) {
await saveFee(s.id, s.amount);
}
// parallel and catchable
await Promise.all(students.map((s) => saveFee(s.id, s.amount)));Note the arrow function in that last line. Passing saveFee directly to map would call it with three arguments, since map supplies the index and the array as well, and the second parameter would silently receive an index instead of an amount.
Why async errors slip past try/catch
try/catch is synchronous and lexical. It protects a stack frame only while that frame is on the stack. When you schedule a callback, the current frame unwinds, control returns to the event loop, and the callback later runs on a completely fresh stack with no memory of the try that scheduled it.
try {
setTimeout(() => {
throw new Error('boom'); // escapes the try entirely
}, 0);
} catch (err) {
console.error('never runs');
}
// -> uncaughtException, process exitsThe same applies to fs.readFile callbacks, stream data handlers and any EventEmitter listener the event loop invokes later. A listener run synchronously by your own emitter.emit() call is the exception, because that one really is still inside the try. Otherwise the error must be handled inside the callback, or forwarded through whatever error channel the API provides. async/await feels different only because await keeps the frame conceptually alive across the suspension, which is exactly what makes the missing-await bug so damaging.
A closely related trap is handing an async function to an API that expects a synchronous callback. forEach, sort, and emitter.on all ignore the returned promise, so a rejection inside has nowhere to go.
Express 4 does this too. It has no idea what to do with a promise returned from a route handler, so a rejection becomes an unhandled rejection and the request hangs rather than reaching your error middleware. Wrap async handlers:
const wrap = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/students', wrap(async (req, res) => {
const rows = await db.students.findAll();
res.json(rows);
}));Express 5 forwards rejections from async handlers to the error middleware automatically, which removes the need for the wrapper. Check which major version your project is on before deciding you do not need it.
Promises created before they are awaited
Starting several operations in parallel and awaiting them one by one looks efficient and contains a real hazard:
const studentsP = fetchStudents(); // takes 2 seconds
const feesP = fetchFees(); // rejects after 100ms
const students = await studentsP; // still waiting here
const fees = await feesP; // never reachedWhile the first await is pending, feesP rejects with nothing attached to it. Node's check runs, sees a rejected promise with no handler, and terminates the process before line four is ever reached. The code is logically correct and still crashes.
Promise.all avoids this because it attaches handlers to every promise the moment you call it:
const [students, fees] = await Promise.all([
fetchStudents(),
fetchFees(),
]);Use Promise.allSettled when you want every result regardless of individual failures; it never rejects and gives you an array of status objects instead. Remember that Promise.all rejects as soon as the first promise fails but does not cancel the others, so any side effects they were performing still happen.
The same hazard appears whenever a promise is stored and consumed later. Caching a request promise in a map so several callers can share one network call is a sensible pattern, but if the request rejects before the second caller arrives to await it, the rejection is unhandled in the gap. Attach a no-op catch at the moment you store it, and let each consumer still await the original promise so they receive the real error.
Two habits make these bugs harder to diagnose. The first is the empty catch: .catch(() => {}) silences the rejection and resolves with undefined, so the next .then receives undefined and you get a property-of-undefined error somewhere unrelated. If you must swallow an error, at least log it. The second is rejecting with a string. Promise.reject('failed') is legal and gives you no stack trace at all, leaving you with a one-word message and no idea which file it came from. Always reject with an Error object.
Process handlers are a net, not a fix
Node lets you observe these events yourself, and doing so is worth it for the logging alone. Registering a handler suppresses the default crash, so you must decide explicitly what happens next:
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
process.exit(1);
});
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
process.exit(1);
});The instinct is to log and keep running, because restarting feels worse than continuing. It is not. An unhandled rejection means some operation failed at a point your code never accounted for, so you have no idea what state the process is in. A half-written database record, a connection never returned to the pool, a lock never released. A process in unknown state serving live requests corrupts data slowly and invisibly.
The better pattern is to log with enough context to debug, stop accepting new connections, give in-flight requests a short grace period, then exit and let your supervisor restart you. pm2, systemd and container orchestrators all restart a failed process automatically, and a fresh process has known state.
process.on('unhandledRejection', (reason) => {
logger.error({ reason }, 'unhandled rejection, shutting down');
server.close(() => process.exit(1));
setTimeout(() => process.exit(1), 5000).unref();
});Treat these handlers as a smoke alarm. They tell you something is burning; they do not put it out. Every rejection they report should be traced back to a specific missing await or .catch and fixed there. In the browser the equivalent hook is window.addEventListener('unhandledrejection', handler), which is useful for sending client-side failures to your logging service.
