How to Handle Async Errors in Node.js the Right Way

Async code fails differently than synchronous code, and Node.js will not always warn you loudly when it does. A missed .catch() or an unwrapped await can silently swallow an error or, worse, crash your entire process. This guide covers the patterns that keep async error handling predictable.

Unhandled Promise Rejections

(node:12345) UnhandledPromiseRejectionWarning: Error: connect ECONNREFUSED 127.0.0.1:5432
(node:12345) UnhandledPromiseRejectionWarning: Unhandled promise rejection.
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().

Since Node.js 15, an unhandled rejection crashes the process by default instead of just printing a warning. This is a good thing — it surfaces bugs early instead of letting them fail silently in production.

// Bad: no .catch(), rejection is unhandled
async function loadUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}
loadUser(42); // if fetch fails, this rejection is never caught

// Good: caught explicitly
loadUser(42).catch(err => console.error('Failed to load user:', err.message));

try/catch with async/await

await converts a Promise rejection into a normal thrown exception inside an async function, so plain try/catch works as expected.

async function getUserOrders(userId) {
  try {
    const user = await db.users.findById(userId);
    if (!user) throw new Error('User not found');

    const orders = await db.orders.findByUserId(userId);
    return orders;
  } catch (err) {
    console.error('getUserOrders failed:', err.message);
    throw err; // re-throw so the caller can also decide what to do
  }
}

A common mistake is forgetting await before a call inside the try block — without it, the Promise rejection happens outside the try/catch's synchronous scope and becomes unhandled.

Async Errors in Express Route Handlers

Express (before v5) does not catch rejected Promises thrown inside async route handlers automatically. This is a very common source of crashed servers in production.

// Dangerous: if db.users.findById throws, Express never sees it,
// the request hangs, and the rejection may crash the process
app.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user);
});

// Safer: wrap every async handler
function asyncHandler(fn) {
  return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}

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

// Central error handler catches everything forwarded via next(err)
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

Popular libraries like express-async-errors patch Express to do this automatically, so you don't need to wrap every single handler by hand.

Handling Multiple Async Operations

  • Promise.all() — rejects immediately if any promise rejects; good when all results are required and a single failure should stop everything
  • Promise.allSettled() — always resolves, giving you the status of each promise; good when partial failures are acceptable
  • Sequential awaits — use when operations depend on each other's results, and you want to fail fast at the first error
const results = await Promise.allSettled([
  fetchUser(1),
  fetchUser(2),
  fetchUser(999), // does not exist, rejects
]);

results.forEach((r, i) => {
  if (r.status === 'rejected') console.error(`User ${i} failed:`, r.reason.message);
});

Global Safety Nets

Use process-level handlers as a last line of defense — log the error and exit gracefully, don't rely on them to keep running indefinitely with corrupted state.

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled Rejection:', reason);
  // log to monitoring, then exit; a process manager (pm2, systemd) restarts it
  process.exit(1);
});

process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  process.exit(1);
});

Frequently Asked Questions

What causes an UnhandledPromiseRejectionWarning in Node.js?

It happens when a Promise rejects (throws inside an async function, or calls reject()) and nothing in your code catches that rejection with a .catch() or a try/catch around an await.

Does try/catch work with async/await in Node.js?

Yes. Wrapping an await call in try/catch works exactly like synchronous error handling, because await unwraps the Promise and re-throws its rejection as a catchable exception inside the async function.

Why do async errors in Express routes crash the server?

Express does not automatically catch errors thrown inside async route handlers in versions before Express 5. If you do not wrap the handler in try/catch or an async error-catching helper, the rejection becomes unhandled and can crash the process.

Debug Errors Faster with AI

Stuck on an unhandled rejection or a confusing async stack trace? Paste it into our free AI Error Explainer for an instant, plain-English fix.

Related articles