Errors, security, and observation
Handle async errors in Express 5
Use rejected promises and thrown errors correctly, then verify the Express 5 behavior you depend on.
Express 5 catches rejected promises from async handlers and middleware and passes them to your error middleware. That one change removes a whole category of bugs from Express 4, where an await that threw left the request hanging forever unless you wrapped every handler.
Let’s see what that means for the notes app, and how to tell expected failures from unexpected ones.
An async handler that just throws
Here is the route that shows a note, reading from a database through a repository:
notesRouter.get('/:id', async (req, res) => {
const note = await notes.find(req.params.id)
if (!note) {
throw new NotFoundError('Note not found')
}
res.json(note)
})
Two things can go wrong. The database call can reject, say because the connection dropped. Or the note can be missing. In both cases the handler throws, the promise rejects, and Express 5 forwards the error to the error middleware. No try, no catch, no next(err).
In Express 4 the same code would leave the client waiting until the socket timed out. You needed a wrapper like asyncHandler(fn) around every route, or the express-async-errors package. Delete those when you migrate.
Expected versus unexpected
A missing note is an expected outcome. The client asked for something that isn’t there, and the answer is 404. A dropped connection is unexpected. The client did nothing wrong, and the answer is 500.
I make the difference visible with a small error class:
export class HttpError extends Error {
constructor(status, message) {
super(message)
this.status = status
}
}
export class NotFoundError extends HttpError {
constructor(message = 'Not found') {
super(404, message)
}
}
The error middleware reads err.status when it exists and falls back to 500. Unknown errors carry no status, so they can’t accidentally leak as anything else.
The mistake that keeps requests open
This pattern looks careful and is broken:
notesRouter.get('/:id', async (req, res) => {
try {
const note = await notes.find(req.params.id)
res.json(note)
} catch (err) {
console.error(err)
}
})
The error is logged and swallowed. Nothing sends a response, so the request hangs. If you catch, you must also answer or rethrow. Most of the time, don’t catch at all and let the error middleware do its job.
Prove it through the real router
Don’t take the behavior on faith. Test it with a repository that rejects:
test('a database failure becomes one safe 500', async () => {
const notes = { find: async () => { throw new Error('connection refused') } }
const app = createApp({ config, notes })
const res = await request(app).get('/api/notes/1')
assert.equal(res.status, 500)
assert.deepEqual(res.body, { error: 'Internal error', requestId: res.headers['x-request-id'] })
})
The assertions check the two sides of the contract. The client gets one response, with a generic message and a request id. And the message connection refused is nowhere in the body. It goes to the log, which we look at in the next lesson.
Try this: run the same test against a fake that returns null, and assert on 404 with the Note not found message. Two tests, two kinds of failure, one error path.
Lesson completed