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 anasync function without a catch block, or by rejecting a promise which was nothandled 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 stackreturn Promise.reject('Request failed');
// better: always reject with an Errorreturn 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 crashmain().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 routesapp.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
| Symptom | Likely cause | Fix |
|---|---|---|
| Crash after upgrading Node.js | An old bug is now fatal (Node 15+) | Steps 2 and 3 |
| Crash only on failed API calls | Awaited call without try/catch | Step 2 |
try/catch exists but app still crashes | Missing await | Step 4 |
| Crash from an Express route | Async handler on Express 4 | Step 5 |
| No stack trace in the reason | Rejecting with a string, not an Error | Step 1 |
| Crash inside an event listener | Async listener without its own catch | Step 6 |
Debug Checklist
- Read the reason in the error. Is it an
Errorwith a stack trace? - Search your code for async functions that are called without
awaitor.catch(). - Check every async Express route and every async event listener.
- Run your linter with the floating-promise rules turned on.
- 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.

0 Comments