CodingNic

Error Handling

Async Error Handling

Error Handling 12 min read

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/catch around await for 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

javascript
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);
});
bash
curl http://localhost:4403/users/1
text
{"id":1,"name":"Erin"}
bash
curl -i http://localhost:4403/users/99
text
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:

javascript
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:

javascript
// BUG: no await, no return
app.get('/users/:id', (req, res) => {
  findUser(req.params.id).then(user => res.json(user));
});
bash
curl http://localhost:4408/users/99
text
(the server process crashes entirely, curl gets no response)

Server-side, before crashing:

text
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

  1. Write an async route handler that awaits a function returning a rejected promise, with no try/catch, and confirm Express 5 still returns a clean 500 rather than crashing.
  2. Add try/catch around the same await, and use next(err) in the catch block instead of letting it propagate automatically.
  3. Deliberately write a “fire-and-forget” route (calling an async function without await or return) that can reject, trigger the rejection, and observe what happens to the server process.
  4. 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 async route handler (or any handler that returns a promise), forwarding it to the error-handling middleware without manual next(err).
  • try/catch around await is still the right tool for custom handling, a specific status code or error type, rather than the generic default 500.
  • A promise created without await or return is 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.