Error Handling in Express vs NestJS: A Deep Architectural Comparison
Error handling is one of the biggest architectural differences between Express and NestJS.
At first glance they look similar — both run on Node.js — but internally they behave very differently when dealing with async errors.
Let’s break it down properly.
1️⃣ NestJS Error Handling — Structured and Automatic
In most cases, you do NOT need try...catch in NestJS.
NestJS has:
- A built-in Global Exception Filter
- Automatic async error handling
- Structured HTTP exception classes
When an error is thrown inside:
- Controllers
- Services (called from controllers)
- Pipes
- Guards
- Interceptors
NestJS catches it automatically.
Example:
async sendEmail() {
await this.transporter.sendMail(...); // if this fails
}
If sendMail() rejects:
- The Promise throws
- NestJS catches it
- Returns
500 Internal Server Error - Sends:
{
"statusCode": 500,
"message": "Internal server error"
}
No try/catch required.
When You DON’T Need try/catch in NestJS
Skip it if:
- Default 500 error is fine
- No custom message needed
- No custom logging required
- No transformation of the error needed
NestJS handles it cleanly.
When You SHOULD Use try/catch in NestJS
1️⃣ When you want a specific HTTP error
import { BadRequestException } from '@nestjs/common';
try {
await something();
} catch (error) {
throw new BadRequestException('Email sending failed');
}
Response:
{
"statusCode": 400,
"message": "Email sending failed"
}
2️⃣ When you need custom logging
- Save to database
- Send to monitoring system
- Add contextual metadata
3️⃣ When outside Nest request lifecycle
Example:
setTimeout(async () => {
await something(); // ❌ Not automatically caught
}, 1000);
NestJS only auto-catches errors inside the request–response lifecycle.
If you:
- Fire background jobs
- Use raw Node callbacks
- Trigger event listeners
You must handle errors manually.
2️⃣ Express Error Handling — Minimal and Manual
Unlike NestJS, Express does NOT automatically catch async errors.
Example:
app.get('/send', async (req, res) => {
await someAsyncFunction(); // ❌ if this throws
res.send('Done');
});
If someAsyncFunction() rejects:
- Express does NOT catch it
- It may trigger
UnhandledPromiseRejection - In some Node versions → it may crash the app
Why?
Because Express was created before async/await existed.
How Express Was Designed
Originally everything was callback-based:
app.get('/route', (req, res) => {
someFunction((err, result) => {
if (err) {
// handle error manually
}
});
});
Internally Express does something like:
try {
routeHandler(req, res, next);
} catch (err) {
next(err);
}
That try/catch only works for synchronous errors.
Synchronous Error (Works)
app.get('/', (req, res) => {
throw new Error('Boom');
});
The error happens immediately in the same call stack. Express catches it.
Async Error (Does NOT Work Automatically)
app.get('/', async (req, res) => {
await somethingAsync();
});
Under the hood:
app.get('/', (req, res) => {
return somethingAsync().then(...);
});
An async function ALWAYS returns a Promise.
When the Promise rejects:
- It does NOT throw immediately
- It rejects in the microtask queue
- The original call stack is already gone
So Express never sees it.
Why Express can’t catch async errors internally
This is about how Promises + Event Loop + Express internals work together.
Step 1: How Express Was Originally Designed
Express.js was created before async/await existed.
Back then everything was callback-based:
app.get('/route', (req, res) => {
someFunction((err, result) => {
if (err) {
// handle error
}
});
});
Express internally wraps route handlers like this (simplified):
try {
routeHandler(req, res, next);
} catch (err) {
next(err);
}
Important:
That try/catch only catches synchronous errors.
Step 2: What is a synchronous error?
app.get('/', (req, res) => {
throw new Error('Boom');
});
This works because:
- Error happens immediately
- It happens inside the same call stack
- Express's try/catch catches it
Step 3: What happens with async/await?
app.get('/', async (req, res) => {
await somethingAsync();
});
Now under the hood, this becomes:
app.get('/', (req, res) => {
return somethingAsync().then(...);
});
An async function ALWAYS returns a Promise.
Here’s the Critical Part
When somethingAsync() fails:
- It does NOT throw immediately
- It rejects a Promise
- That rejection happens later in the microtask queue
By that time:
- Express already finished executing the route handler
- The original try/catch is gone
- The call stack is empty
So Express never sees the error.
Visual Timeline
Synchronous
Call stack:
Express -> Route -> throw error
↑ caught here
Async
Call stack:
Express -> Route -> returns Promise
Call stack ends
Later...
Microtask queue:
Promise rejects ❌
Nobody catches it
That’s the key.
Why NestJS Doesn't Have This Problem
NestJS wraps everything internally like this:
Promise.resolve(handler(req, res)).catch(errorHandler);
It ALWAYS treats handlers as Promises.
So:
- If sync → wrapped into Promise
- If async → already Promise
- Any rejection → caught centrally
That’s why NestJS auto-handles async errors.
Deep JavaScript Reason
JavaScript has:
- Call Stack
- Web APIs
- Task Queue
- Microtask Queue (Promises)
Promise rejections are handled in the microtask queue, which runs AFTER the current call stack is cleared.
Express’s try/catch cannot travel across event loop ticks.
That’s a core limitation of how JavaScript works.
Why Express Didn’t “Fix” It
Because:
- It would break backward compatibility
- Express tries to stay minimal
- Many apps already rely on manual error handling
So instead, people use wrappers like:
Promise.resolve(fn(req, res, next)).catch(next);
What Happens If Errors Are NOT Handled?
This is critical.
If a Promise rejection is not handled:
1️⃣ UnhandledPromiseRejection Warning
Node prints:
UnhandledPromiseRejectionWarning
2️⃣ Process May Crash
In modern Node versions:
- Unhandled rejections can terminate the process
- Your server stops
- All requests fail
3️⃣ Memory Leaks
If errors occur repeatedly without proper cleanup:
- Open DB connections remain
- File descriptors stay open
- Event listeners accumulate
4️⃣ Security Risks
Uncaught errors may:
- Expose stack traces
- Leak sensitive information
- Leave inconsistent system state
5️⃣ Partial Responses
Client may receive:
- Half-sent HTTP responses
- Corrupted JSON
- Hanging requests
NestJS vs Express Comparison
| Feature | Express | NestJS |
|---|---|---|
| Async error auto-handling | ❌ No | ✅ Yes |
| Built-in global filter | ❌ No | ✅ Yes |
| Structured HTTP exceptions | ❌ No | ✅ Yes |
| Architecture | Minimal | Structured & layered |
| Requires wrappers | Usually yes | No |
Architectural Philosophy
Express
- Minimal
- Unopinionated
- You control everything
- You handle everything
NestJS
- Opinionated
- Structured
- Centralized exception system
- Designed for large-scale applications
Professional Pattern in Large Systems
In serious applications:
- Services throw domain errors
- Controllers translate to HTTP exceptions
- Global filter formats responses
- Logging is centralized
- Process-level error handling exists
You do NOT wrap every await.
That creates noise and hides architecture.