Header Ads

Fix UnhandledPromiseRejection and ERR_UNHANDLED_REJECTION crashes in Node.js and Express with try/catch, async handlers and a safe shutdown.

Is your Node.js or Express app crashing with “UnhandledPromiseRejection” or ERR_UNHANDLED_REJECTION? This guide explains why it happens on modern Node.js and shows 6 fixes, from try/catch to Express async handlers, with working code.

Your app runs fine for hours, then one failed request kills the whole process, and the terminal prints something like this:

node:internal/process/promises:288
triggerUncaughtException(err, true /* fromPromise */);
^

[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 "Request failed: 500".] {
code: 'ERR_UNHANDLED_REJECTION'
}

The exact lines differ between Node.js versions, and if the rejection was a real Error object you will see its stack trace instead. On older Node.js versions, you only got a warning that said UnhandledPromiseRejectionWarning and the app kept running.

Why Does This Happen?

A promise can end in two ways: it resolves with a value, or it rejects with an error. If a promise rejects and nothing in your code handles that rejection, with try/catch around an await or a .catch(), Node.js calls it an unhandled rejection.

Since Node.js 15, the default behavior is to treat an unhandled rejection like an uncaught exception, which stops the process with a non-zero exit code. That is why an app that “worked before” can suddenly crash after you upgrade Node.js. The bug was always there, but it used to be only a warning.


Step 1: Find Where the Rejection Comes From

If the error is an Error object, the stack trace shows the file and line. Start there. The hard cases are rejections with a plain string or object, which have no stack trace:

// hard to trace: no stack
return Promise.reject('Request failed');

// better: always reject with an Error
return Promise.reject(new Error('Request failed'));

If you cannot tell where it comes from, add a temporary logger that prints the reason and the promise, then run the app again:

process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise);
console.error('Reason:', reason);
});

Remove or replace this once you have found the bug. A safer long-term version is in Step 7.


Step 2: Wrap await Calls in try/catch

The most common cause is an async function that awaits something that can fail, with no try/catch and no caller handling the error:

async function getUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}

getUser(1); // if this rejects, nobody handles it and the process crashes

(The global fetch used here is built into Node.js 18 and newer.) Handle the error where you call the function:

async function main() {
try {
const user = await getUser(1);
console.log(user);
} catch (err) {
console.error('Failed to load user:', err.message);
process.exitCode = 1;
}
}

main();


Step 3: Handle Promise Chains and Top-Level Calls

If you use .then(), finish the chain with .catch(). The same applies to the very first call that starts your program:

getUser(1)
.then((user) => console.log(user))
.catch((err) => console.error('Failed:', err.message));

// an async main() that is never handled is a classic crash
main().catch((err) => {
console.error(err);
process.exit(1);
});


Step 4: Do Not Forget await (and Catch Floating Promises)

A missing await turns a handled error into an unhandled one, because the try/catch finishes before the promise rejects:

async function save(data) {
try {
saveToDatabase(data); // missing await: the error escapes the try/catch
} catch (err) {
console.error('never reached', err);
}
}

async function saveFixed(data) {
try {
await saveToDatabase(data); // now the catch works
} catch (err) {
console.error('Save failed:', err.message);
}
}

Linters can find these mistakes for you. In TypeScript projects, enable the @typescript-eslint/no-floating-promises rule. In JavaScript projects, the promise/catch-or-return rule from eslint-plugin-promise catches promise chains without a .catch().


Step 5: Fix Async Route Handlers in Express

In Express 4, an error thrown inside an async route handler is not passed to your error middleware. It becomes an unhandled rejection instead and can crash the app. Express 5 handles rejected promises from route handlers for you, so check which version you use with npm ls express.

For Express 4, wrap your async handlers so rejections reach next(err), and add one central error handler:

const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);

app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ message: 'Not found' });
res.json(user);
}));

// error middleware: four arguments, registered after all routes
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ message: 'Internal server error' });
});


Step 6: Handle Parallel Tasks and Event Handlers

Promise.all rejects as soon as one promise fails. If you want every result, even when some fail, use Promise.allSettled and check each outcome:

const results = await Promise.allSettled([taskA(), taskB(), taskC()]);

results.forEach((result) => {
if (result.status === 'rejected') {
console.error('Task failed:', result.reason);
}
});

Async event handlers are another hidden source. Node.js does not wait for them or catch their errors, so handle errors inside the function:

emitter.on('order', async (order) => {
try {
await saveOrder(order);
} catch (err) {
console.error('Could not save order:', err.message);
}
});


Step 7: Add a Safety Net, Not a Cover-Up

After fixing the real causes, add a global handler as the last line of defense. Its job is to log the problem and shut down cleanly, so your process manager can restart a healthy process:

const server = app.listen(3000, () => console.log('API running on port 3000'));

function shutdown(reason) {
console.error('Fatal error:', reason);
server.close(() => process.exit(1));
setTimeout(() => process.exit(1), 10000).unref(); // force exit if close hangs
}

process.on('unhandledRejection', (reason) => shutdown(reason));
process.on('uncaughtException', (err) => shutdown(err));

With PM2, Docker or systemd restarting the app, users see a short blip instead of a long outage, and your logs show what happened. Do not use these handlers to silently ignore errors and keep running, because the app may be in a broken state.


Angular and Ionic: “Uncaught (in promise)” in the Browser

The same idea applies in your frontend. In Angular and Ionic, you may see ERROR Error: Uncaught (in promise): ... in the console. Capacitor plugin calls are a common source, because many of them reject when something goes wrong, for example when the user cancels the camera. Wrap them in try/catch:

import { Camera, CameraResultType } from '@capacitor/camera';

async takePhoto() {
try {
const photo = await Camera.getPhoto({ resultType: CameraResultType.Uri });
this.photoUrl = photo.webPath;
} catch (err) {
console.warn('Camera cancelled or failed', err);
}
}


What Not to Do

You will find advice to start Node.js with a flag that brings back the old behavior:

node --unhandled-rejections=warn server.js

This makes Node.js print a warning and keep running, like before Node.js 15. Other values include strict, none and warn-with-error-code. Use these only as a short-term bridge on a legacy project. They hide bugs instead of fixing them, and an app that keeps running after a failed operation can corrupt data or behave strangely.


Quick Diagnosis Table

SymptomLikely causeFix
Crash after upgrading Node.jsAn old bug is now fatal (Node 15+)Steps 2 and 3
Crash only on failed API callsAwaited call without try/catchStep 2
try/catch exists but app still crashesMissing awaitStep 4
Crash from an Express routeAsync handler on Express 4Step 5
No stack trace in the reasonRejecting with a string, not an ErrorStep 1
Crash inside an event listenerAsync listener without its own catchStep 6


Debug Checklist

  1. Read the reason in the error. Is it an Error with a stack trace?
  2. Search your code for async functions that are called without await or .catch().
  3. Check every async Express route and every async event listener.
  4. Run your linter with the floating-promise rules turned on.
  5. Add the Step 7 shutdown handler so any remaining case is logged and restarted cleanly.


Conclusion

Unhandled promise rejections are not mysterious: somewhere a promise failed and no code was waiting to deal with it. Since Node.js 15, that mistake stops your process, which actually helps you find it. Use try/catch around await, finish promise chains with .catch(), wrap async Express routes, and keep a clean shutdown handler as a safety net. Your app will then fail loudly, log clearly and restart cleanly.

Related Guides

Post a Comment

0 Comments