Introduction

Since Node.js v15, unhandled promise rejections cause the process to exit with a non-zero code. Previously, they only emitted a warning. Any async function that rejects without a .catch() handler, or any await that throws without a try/catch, will crash the entire process. This makes proper error handling in async code critical for production stability.

Symptoms

  • node:internal/process/promises:288 triggerUncaughtException(err, true);
  • UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block
  • Process exits with code 1 after async error
  • [UnhandledPromiseRejection: Error: connect ETIMEDOUT]
  • Server crashes on a single failed API call

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

[UnhandledPromiseRejection: Error: connect ETIMEDOUT 10.0.0.1:443] code: 'ETIMEDOUT', errno: -110, syscall: 'connect', address: '10.0.0.1', port: 443 ]

Node.js v20.11.0 ```

Common Causes

  • Async function without try/catch around await
  • Promise chain without .catch() at the end
  • Fire-and-forget async calls: asyncOperation() without await or catch
  • Error thrown inside .then() without downstream .catch()
  • EventEmitter async handlers that reject

Step-by-Step Fix

  1. 1.Add global unhandled rejection handler:
  2. 2.```javascript
  3. 3.// Prevent crash and log the error
  4. 4.process.on('unhandledRejection', (reason, promise) => {
  5. 5.console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  6. 6.// In production, send to error tracking service
  7. 7.// Sentry.captureException(reason);
  8. 8.});

// NOTE: This prevents the crash but does not fix the underlying bug // Always fix the root cause - do not rely on this handler long-term ```

  1. 1.Use try/catch with async/await:
  2. 2.```javascript
  3. 3.// WRONG - rejection not caught
  4. 4.async function fetchData() {
  5. 5.const response = await fetch('https://api.example.com/data');
  6. 6.const data = await response.json(); // Throws if response is not JSON
  7. 7.return data;
  8. 8.}
  9. 9.fetchData(); // Unhandled rejection if fetch fails

// CORRECT - wrap in try/catch async function fetchData() { try { const response = await fetch('https://api.example.com/data'); if (!response.ok) { throw new Error(HTTP ${response.status}: ${response.statusText}); } const data = await response.json(); return data; } catch (error) { console.error('Failed to fetch data:', error.message); throw error; // Re-throw if caller should handle it } }

// Caller must also handle try { const data = await fetchData(); } catch (error) { // Handle or log } ```

  1. 1.Use .catch() for fire-and-forget operations:
  2. 2.```javascript
  3. 3.// WRONG - no error handling
  4. 4.async function sendAnalytics(event) {
  5. 5.await fetch('https://analytics.example.com/track', {
  6. 6.method: 'POST',
  7. 7.body: JSON.stringify(event),
  8. 8.});
  9. 9.}
  10. 10.sendAnalytics({ page: '/home' }); // Unhandled rejection if fetch fails

// CORRECT - catch errors even for fire-and-forget function sendAnalytics(event) { fetch('https://analytics.example.com/track', { method: 'POST', body: JSON.stringify(event), }).catch((err) => { // Log but don't crash - analytics failure is not critical console.error('Analytics send failed:', err.message); }); } ```

  1. 1.Handle errors in Express async route handlers:
  2. 2.```javascript
  3. 3.// WRONG - async errors not caught by Express
  4. 4.app.get('/users/:id', async (req, res) => {
  5. 5.const user = await User.findById(req.params.id);
  6. 6.res.json(user); // Error if user not found or DB fails
  7. 7.});

// CORRECT - wrap in try/catch or use async handler wrapper const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };

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

  1. 1.Debug which promise is unhandled:
  2. 2.```bash
  3. 3.# Run with detailed rejection tracking
  4. 4.node --trace-uncaught app.js

# Or set environment variable NODE_OPTIONS="--trace-uncaught" npm start

# This shows the full promise chain and where the rejection originated ```

Prevention

  • Always use try/catch with async/await or .catch() on promise chains
  • Use express-async-errors or express-async-handler for Express routes
  • Add process.on('unhandledRejection') as a safety net (not a fix)
  • Use ESLint rule no-floating-promises from @typescript-eslint
  • Write tests that verify error paths in async functions
  • Use await-to-js pattern for cleaner error handling:
  • ```javascript
  • const to = require('await-to-js').default;
  • const [err, data] = await to(fetchData());
  • if (err) { /* handle error */ }
  • `