NodeJStry/catch with async/await

try/catch with async/await

async/await makes asynchronous code read like synchronous code — and that includes error handling. A throw inside an async function rejects the returned Promise; an await on a rejected Promise throws inside the surrounding async function, where it can be caught with try/catch. The model is consistent and predictable, but there are several footguns: forgetting to await, not forwarding errors to Express's next, and swallowing errors in overly broad catches.

The async/await error model

JS
async function fetchUser(id) {
  const user = await db.users.findById(id)   // throws if the DB call rejects
  if (!user) throw new Error('User not found')
  return user
}

// Calling it:
try {
  const user = await fetchUser(42)
} catch (err) {
  // Catches BOTH the DB rejection AND the "not found" throw
  console.error(err.message)
}
`throw` in an async function === promise rejection; `await` propagates it as a throw
The symmetry is: inside an `async` function, `throw` turns into rejection; outside, `await` turns rejection back into a thrown error that `try/catch` can intercept. The call stack unwinds just as it would for synchronous code, through every layer of `await`ed calls. This means you can have one `try/catch` at the top of a route handler that catches failures from many layers deep — as long as every intermediate `async` function properly `await`s its work.
The missing await — errors silently disappear

JS
// ❌ No await — fire-and-forget; errors vanish:
app.get('/bad', (req, res) => {
  sendEmail(req.body.email)       // Promise returned but not awaited
    // If sendEmail rejects: unhandled rejection → crash in Node 15+
  res.json({ ok: true })
})

// ✅ await it, or attach .catch():
app.get('/ok', async (req, res, next) => {
  try {
    await sendEmail(req.body.email)
    res.json({ ok: true })
  } catch (err) {
    next(err)
  }
})
An un-awaited Promise swallows its own error — always await or `.catch()` every Promise
Calling an async function without `await` starts the work but **disconnects from its result**. If it rejects, there's no catch, and Node (15+) will crash the process on an unhandled rejection. Even when the code "seems to work," you're creating a silent failure mode. Every Promise must either be `await`ed inside a `try/catch`, chained with `.catch()`, or returned to a caller who does one of those. If you genuinely want fire-and-forget, attach at minimum `.catch(err => logger.error(err))`.
Express: async handlers need try/catch + next(err)

JS
// ❌ A thrown error inside an async handler crashes the process:
app.get('/user/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id)   // might throw!
  res.json(user)
})

// ✅ Wrap and forward to Express error middleware via next:
app.get('/user/:id', async (req, res, next) => {
  try {
    const user = await db.users.findById(req.params.id)
    res.json(user)
  } catch (err) {
    next(err)            // hands to the 4-argument error middleware
  }
})
Express does NOT automatically catch rejected Promises from async handlers (pre-5.x)
Express 4.x's router doesn't wrap handlers in Promise machinery; an unhandled rejection from an async handler is not caught by Express error middleware — it propagates as an unhandled rejection. You must either wrap each handler in `try/catch + next(err)`, use a wrapper utility, or upgrade to Express 5 (which handles this natively). This is the single most common async error-handling mistake in Express codebases.
The asyncHandler wrapper — removing boilerplate

JS
// Write once:
export const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next)

// Use everywhere — the try/catch boilerplate disappears:
app.get('/user/:id', asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id)
  if (!user) throw new HttpError(404, 'User not found')
  res.json(user)
}))
An asyncHandler wrapper forwards rejections to next() without repeating try/catch
`asyncHandler` wraps any async route function and calls `.catch(next)` on its Promise. Any thrown error — from the handler itself or from any `await`ed call — is forwarded to Express's error middleware. The package `express-async-handler` on npm does exactly this. With it, your route handlers are clean async functions that throw on errors; the infrastructure handles forwarding.
Don't catch what you can't handle

JS
// ❌ Swallowing an error you don't know how to handle:
try {
  await db.users.create(data)
} catch (err) {
  console.log('oops')    // error eaten — no response, no rethrow
}

// ✅ Catch only what you EXPECT; re-throw the rest:
try {
  await db.users.create(data)
} catch (err) {
  if (err.code === '23505') {           // unique violation — expected
    throw new HttpError(409, 'Email already taken')
  }
  throw err                            // unexpected — propagate up
}
A catch block that does nothing is worse than no catch block
An empty or silent catch is an **error sink** — the error is gone, the request hangs with no response (or a wrong response), and you have no log to debug from. Only catch errors you can meaningfully handle. For anything unexpected, `throw err` (or `throw err` after logging) so it propagates to the [centralised handler](/nodejs/centralized-error-handling) where it will be logged and turned into a 500. The rule: if you don't know what to do with an error, don't pretend you do.
Parallel async calls — Promise.all propagation

JS
// If ANY promise in Promise.all rejects, the whole call rejects:
try {
  const [user, posts] = await Promise.all([
    db.users.findById(id),
    db.posts.findByAuthor(id),
  ])
} catch (err) {
  // Catches whichever rejected first — handle or propagate
  next(err)
}

// Promise.allSettled — get each result regardless of rejection:
const results = await Promise.allSettled([fetchA(), fetchB()])
results.forEach(r => {
  if (r.status === 'rejected') logger.error(r.reason)
})
Next
Know which errors to handle vs which to let crash: [Operational vs Programmer Errors](/nodejs/operational-vs-programmer).