Node.js3 min read

Unhandled Promise Rejection in Node.js and Next.js: Fix Guide

Author:Rutik Vasani

What is an Unhandled Promise Rejection?

An unhandled promise rejection occurs when an asynchronous operation (a Promise) fails or throws an error, but no .catch() block or try/catch mechanism is in place to handle that failure. In Node.js (since version 15+), this results in the entire process crashing with an ERR_UNHANDLED_REJECTION code. This is dangerous because it can instantly take down a server, often leaving very little context in the logs.


An unhandled promise rejection is one of the most frustrating errors for Node.js developers. It means a promise failed and the application didn't know what to do with it, so it gave up and died. In modern frameworks like Next.js, this often manifests as flaky API routes or server actions that work perfectly on your local machine but return random 500 errors in production under load.

Common Causes in Modern JavaScript

If you are seeing these crashes, check your codebase for these common anti-patterns:

  1. Missing await + No Catch: You fire off an asynchronous API route handler but forget to await a database call, and don't wrap it in a try/catch.
  2. Promise.all Explosions: When using Promise.all([req1, req2]), if req1 fails, the whole block rejects. If not wrapped in a try/catch, it brings down the server.
  3. Fire-and-Forget fetch(): Calling fetch('/api/analytics') without awaiting it or appending a .catch() block. If the network drops, the rejection bubbles up to the global scope.
  4. Express Async Handlers: Using async functions in Express middleware without a wrapper that passes rejected promises to next(err).

The Quick Fixes

You need to wrap your async routes and ensure every promise has a handler.

Express Middleware Wrapper

If you are using Express, wrap your async routes to ensure errors are passed to your error handler:

// Wrap async routes
const asyncH = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next)

app.get('/api/data', asyncH(async (req, res) => {
  const data = await fetchData();
  res.json(data);
}));

Always Await with Context

When executing critical logic, wrap it in a try/catch and throw a rich error that gives you context.

try {
  await processCheckout(payload);
} catch (err) {
  // Add context so your log drain actually helps you debug
  throw Object.assign(new Error('checkout_failed'), { cause: err, userId: payload.userId });
}

Global Listeners and Auto-Remediation

You should add a global listener that reports the unhandled rejection to your error tracker before exiting the process. This ensures your process manager (like PM2 or Docker) restarts cleanly instead of running in a corrupted memory state.

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Send to Relia/Sentry here
  process.exit(1);
});

The best approach, however, is using an autonomous tool. Relia captures these unhandled rejections complete with the request context, stack trace, and local variables. More importantly, it drafts the missing catch block fix automatically, sending a PR directly to your repo. It turns a fatal server crash into a one-click fix.

FAQ

How do I find unhandled rejections?

Search logs for unhandledRejection, add a tracker SDK that auto-captures them, and test locally with node --unhandled-rejections=strict.

Why only in production?

Real latency + real race conditions. Local DB is fast, prod times out.

Should I silence them?

No. Fix the missing catch. Silencing hides checkout failures.

[ MORE ARTICLES ]

Read Next

View all →