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).