← Back to postsCoding Notes
EnglishPublished Mar 3, 2026Updated Mar 3, 20265 min read

Error Handling in Express vs NestJS: A Deep Architectural Comparison

Tips

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:

ts
async sendEmail() {
  await this.transporter.sendMail(...); // if this fails
}

If sendMail() rejects:

  • The Promise throws
  • NestJS catches it
  • Returns 500 Internal Server Error
  • Sends:
json
{
  "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

ts
import { BadRequestException } from '@nestjs/common';

try {
  await something();
} catch (error) {
  throw new BadRequestException('Email sending failed');
}

Response:

json
{
  "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:

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

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

js
app.get('/route', (req, res) => {
  someFunction((err, result) => {
    if (err) {
      // handle error manually
    }
  });
});

Internally Express does something like:

js
try {
  routeHandler(req, res, next);
} catch (err) {
  next(err);
}

That try/catch only works for synchronous errors.


Synchronous Error (Works)

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

js
app.get('/', async (req, res) => {
  await somethingAsync();
});

Under the hood:

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

js
app.get('/route', (req, res) => {
  someFunction((err, result) => {
    if (err) {
      // handle error
    }
  });
});

Express internally wraps route handlers like this (simplified):

js
try {
  routeHandler(req, res, next);
} catch (err) {
  next(err);
}

Important: That try/catch only catches synchronous errors.


Step 2: What is a synchronous error?

js
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?

js
app.get('/', async (req, res) => {
  await somethingAsync();
});

Now under the hood, this becomes:

js
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

code
Call stack:
Express -> Route -> throw error
           ↑ caught here

Async

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

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

  1. It would break backward compatibility
  2. Express tries to stay minimal
  3. Many apps already rely on manual error handling

So instead, people use wrappers like:

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

code
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

FeatureExpressNestJS
Async error auto-handling❌ No✅ Yes
Built-in global filter❌ No✅ Yes
Structured HTTP exceptions❌ No✅ Yes
ArchitectureMinimalStructured & layered
Requires wrappersUsually yesNo

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.