Async Error Handling
Objectives
By the end of this lesson, you should be able to:
- Explain how Express 5 automatically catches a rejected promise inside a route handler
- Use
try/catcharoundawaitfor custom async error handling - Recognize the “fire-and-forget” mistake that hides an error from Express entirely
💡 Why this matters: Almost every real route handler is asynchronous, a database query, an API call, a file read. How Express handles a failed asynchronous operation determines whether an error is caught cleanly or crashes the server outright.
⚠️ A note on verification: every snippet and every response shown below was actually run and tested with real HTTP requests, using Express 5.
Express 5 Catches Rejected Promises Automatically
function findUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === '1') resolve({ id: 1, name: 'Erin' });
else reject(new Error('User not found'));
}, 10);
});
}
app.get('/users/:id', async (req, res) => {
const user = await findUser(req.params.id);
res.json(user);
});
curl http://localhost:4403/users/1
{"id":1,"name":"Erin"}
curl -i http://localhost:4403/users/99
HTTP/1.1 500 Internal Server Error
Error: User not found
at Timeout._onTimeout (/tmp/node108/server3.js:10:16)
...
No try/catch, no manual next(err), and yet a rejected await still produces a 500 instead of crashing the server or hanging the request, this is genuinely new in Express 5 (earlier versions required manually forwarding every async error), an async route handler always returns a promise, and Express 5 automatically catches a rejection on that promise and forwards it to the error-handling middleware, exactly as if next(err) had been called.
Custom Handling with try/catch
Automatic catching still just falls through to a generic 500, custom handling (a specific status code, a friendlier message) still needs try/catch:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await findUser(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
});
Wrapping await in try/catch and calling next(err) in the catch block (covered in full in the next lesson) is still the standard, explicit pattern, especially once a custom error class (Lesson 4) needs to be thrown for a specific situation, like a 404 for a genuinely missing user rather than a generic 500.
The Fire-and-Forget Trap
Express 5’s automatic catching only works because an async function’s returned promise is visible to Express. If a promise is created but never awaited or returned, Express never sees it at all:
// BUG: no await, no return
app.get('/users/:id', (req, res) => {
findUser(req.params.id).then(user => res.json(user));
});
curl http://localhost:4408/users/99
(the server process crashes entirely, curl gets no response)
Server-side, before crashing:
Error: User not found
at Timeout._onTimeout (/tmp/node108/server8.js:8:19)
...
Node.js v22.22.3
This is a genuinely dangerous bug, an unhandled rejection isn’t just invisible to Express, modern Node.js treats an unhandled rejection as a fatal error and terminates the entire process, taking down every other in-flight request along with it, far worse than a single bad 500 response. The fix is always the same: await the promise, or explicitly return it, so it’s connected to the handler’s own returned promise, exactly as both working examples above did.
Try It
- Write an
asyncroute handler thatawaits a function returning a rejected promise, with notry/catch, and confirm Express 5 still returns a clean500rather than crashing. - Add
try/catcharound the sameawait, and usenext(err)in thecatchblock instead of letting it propagate automatically. - Deliberately write a “fire-and-forget” route (calling an async function without
awaitorreturn) that can reject, trigger the rejection, and observe what happens to the server process. - Explain, in your own words, why an unhandled promise rejection is more dangerous than a caught one, even though both originate from the exact same failed operation.
Recap
- Express 5 automatically catches a rejected promise returned by an
asyncroute handler (or any handler thatreturns a promise), forwarding it to the error-handling middleware without manualnext(err). try/catcharoundawaitis still the right tool for custom handling, a specific status code or error type, rather than the generic default500.- A promise created without
awaitorreturnis invisible to Express, an unhandled rejection from it can crash the entire Node process, not just fail one request.
Next lesson: next(), exactly how Express expects an error to be reported.