Production-Grade Error Handling in Node.js & Express

Production-Grade Error Handling in Node.js & Express

A beginner's journey from duplicated try-catch blocks to centralized error middleware


The Problem {#the-problem}

When I started building Express backends, I was confused about:

  • ❓ Where to handle errors - controllers or middleware?
  • ❓ How to send proper HTTP status codes (400, 401, 404, 500)?
  • ❓ How to avoid repeating try-catch everywhere?
  • ❓ What is error middleware and why do I need it?

Phase 1: Inline Error Responses {#phase-1}

What I Initially Wrote

// ❌ PHASE 1: No error handling for async operations export const register = async (req, res) => { const { email, password } = req.body; if (!email) { res.status(400).json({ error: "Email required" }); return; } if (!password) { res.status(400).json({ error: "Password required" }); return; } // ⚠️ This WILL crash if database is down! const user = await prisma.user.create({ data: { email, password } }); res.status(201).json({ user }); };

Problems

  • ❌ No error handling for async operations
  • ❌ Unhandled promise rejections crash the app
  • ❌ Inconsistent error formats
  • ❌ Validation mixed with business logic

Lesson Learned: We need try-catch for async operations!


Phase 2: Adding Try-Catch {#phase-2}

The "Fix"

// ✅ Better: Won't crash, but duplicated everywhere export const register = async (req, res) => { try { const { email, password } = req.body; if (!email) { res.status(400).json({ error: "Email required" }); return; } const user = await prisma.user.create({ data: { email, password } }); res.status(201).json({ user }); } catch (error) { console.error(error); res.status(500).json({ error: "Registration failed" }); } }; // Repeat this pattern 50+ times... 😫

New Problems

  • ❌ Massive code duplication (same try-catch in 50+ functions)
  • ❌ Inconsistent error messages
  • ❌ All errors become 500, even validation errors
  • ❌ Lose context about what actually failed

Lesson Learned: We need to distinguish between error types!


Phase 3: Throwing Errors {#phase-3}

Throw Instead of Return

// 🔄 BETTER: Throw errors, catch handles responses export const register = async (req, res) => { try { if (!email) throw new Error("Email required"); if (!password) throw new Error("Password required"); const user = await prisma.user.create({ data: { email, password } }); res.status(201).json({ user }); } catch (error) { console.error(error); res.status(400).json({ error: error.message }); } };

Still Have Problems

  • ❌ All errors get same status code (400)
  • ❌ Catch block repeated everywhere
  • ❌ Can't differentiate validation vs auth vs not found errors

Lesson Learned: We need custom error classes with status codes!


Phase 4: Custom Error Classes {#phase-4}

Create Error Classes

// src/utils/errors.ts class AppError extends Error { statusCode: number; constructor(statusCode: number, message: string) { super(message); this.statusCode = statusCode; } } class ValidationError extends AppError { constructor(message: string) { super(400, message); // Always 400 } } class UnauthorizedError extends AppError { constructor(message: string) { super(401, message); // Always 401 } } class NotFoundError extends AppError { constructor(message: string) { super(404, message); // Always 404 } } class ConflictError extends AppError { constructor(message: string) { super(409, message); // Always 409 } }

Use in Controllers

// 🔄 BETTER: Different error types with correct status codes export const register = async (req, res) => { try { if (!email) throw new ValidationError("Email required"); // 400 const existing = await prisma.user.findUnique({ where: { email } }); if (existing) throw new ConflictError("Email already registered"); // 409 const user = await prisma.user.create({ data: { email, password } }); res.status(201).json({ user }); } catch (error) { console.error(error); if (error instanceof AppError) { res.status(error.statusCode).json({ error: error.message }); } else { res.status(500).json({ error: "Server error" }); } } };

Remaining Problem

  • ❌ Still duplicating catch block in every controller

Lesson Learned: We need to centralize error handling!


Phase 5: Error Middleware {#phase-5}

Understanding Middleware

Middleware is a function Express calls in sequence:

Request → Middleware 1 → Middleware 2 → Controller → Response

The Magic: Express identifies error handlers by counting parameters!

// Normal middleware (3 parameters) app.use((req, res, next) => { console.log("Normal middleware"); next(); }); // Error middleware (4 parameters) ← The magic! app.use((err, req, res, next) => { // ^^^ Extra parameter makes it an error handler! console.log("Error middleware"); res.status(500).json({ error: err.message }); });

When you call next(error), Express skips all normal middleware and jumps to the first function with 4 parameters!

Create Error Middleware

// src/middlewares/error.middleware.ts export const errorHandler = ( err: Error, // ← 1st parameter req: Request, // ← 2nd parameter res: Response, // ← 3rd parameter next: NextFunction // ← 4th parameter (makes it error handler!) ): void => { console.error("Error:", err); // Check if it's our custom error if (err instanceof AppError) { res.status(err.statusCode).json({ success: false, message: err.message, }); return; } // Unknown error - default to 500 res.status(500).json({ success: false, message: "Internal server error", }); };

Register in Server (MUST BE LAST!)

// src/server.ts const app = express(); // Normal middleware app.use(express.json()); // Routes app.use("/api/auth", authRoutes); // ✅ Error handler MUST be registered LAST! app.use(errorHandler); app.listen(4000);

Update Controllers

// ✅ MUCH BETTER: Pass errors to middleware export const register = async (req, res, next) => { try { if (!email) throw new ValidationError("Email required"); const existing = await prisma.user.findUnique({ where: { email } }); if (existing) throw new ConflictError("Email already registered"); const user = await prisma.user.create({ data: { email, password } }); res.status(201).json({ user }); } catch (error) { next(error); // ← Pass to middleware! } };

Visual Flow

Controller throws error
         ↓
Catch block: next(error)
         ↓
Express skips normal middleware
         ↓
Error middleware runs
         ↓
Sends response to client

Remaining Problem

  • ❌ Still have try-catch in every controller

Lesson Learned: Can we eliminate try-catch entirely?


Phase 6: AsyncHandler - The Final Solution! {#phase-6}

The Problem

// Async function returns a Promise export const register = async (req, res) => { throw new Error("Oops"); // Creates rejected Promise }; // Express doesn't catch Promise rejections automatically! // Result: Unhandled Promise Rejection 💥

The Solution: AsyncHandler Wrapper

// src/utils/asyncHandler.ts export const asyncHandler = ( fn: (req: Request, res: Response, next: NextFunction) => Promise<void> ) => { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch(next); }; };

How It Works

Think of it as automatic try-catch:

// Without asyncHandler - manual try-catch export const register = async (req, res, next) => { try { const user = await prisma.user.create({ data: req.body }); res.json({ user }); } catch (error) { next(error); } }; // With asyncHandler - automatic try-catch export const register = asyncHandler(async (req, res) => { const user = await prisma.user.create({ data: req.body }); res.json({ user }); // asyncHandler automatically does .catch(next) for us! });

Final Controllers - Clean & Beautiful!

// ✅ PERFECT: No try-catch needed! export const register = asyncHandler(async (req: Request, res: Response) => { const { email, password } = req.body; if (!email) throw new ValidationError("Email required"); const existing = await prisma.user.findUnique({ where: { email } }); if (existing) throw new ConflictError("Email already registered"); const user = await prisma.user.create({ data: { email, password } }); res.status(201).json({ success: true, data: { user }, }); }); export const login = asyncHandler(async (req: Request, res: Response) => { const { email, password } = req.body; const user = await prisma.user.findUnique({ where: { email } }); if (!user) throw new UnauthorizedError("Invalid credentials"); res.json({ success: true, data: { user }, }); });

Common Pitfalls {#pitfalls}

Pitfall 1: Wrong Number of Parameters

// ❌ WRONG: Only 2 parameters app.use((err, res) => { res.status(500).json({ error: err.message }); }); // ✅ CORRECT: MUST have 4 parameters app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); });

Pitfall 2: Error Handler in Wrong Position

// ❌ WRONG: Before routes app.use(errorHandler); app.use("/api/auth", authRoutes); // ✅ CORRECT: After routes app.use("/api/auth", authRoutes); app.use(errorHandler);

Pitfall 3: Forgetting next() Parameter

// ❌ WRONG: No next parameter export const register = async (req, res) => { try { // ... } catch (error) { // Can't pass to middleware! } }; // ✅ CORRECT: Include next export const register = async (req, res, next) => { try { // ... } catch (error) { next(error); } };

Complete Example {#complete}

Project Structure

src/
├── controllers/
│   └── auth.controller.ts
├── middlewares/
│   └── error.middleware.ts
├── routes/
│   └── auth.route.ts
├── utils/
│   ├── errors.ts
│   └── asyncHandler.ts
└── server.ts

1. Error Classes

// src/utils/errors.ts export class AppError extends Error { constructor( public statusCode: number, public message: string ) { super(message); } } export class ValidationError extends AppError { constructor(message: string) { super(400, message); } } export class UnauthorizedError extends AppError { constructor(message: string = "Unauthorized") { super(401, message); } } export class NotFoundError extends AppError { constructor(message: string = "Not found") { super(404, message); } } export class ConflictError extends AppError { constructor(message: string = "Already exists") { super(409, message); } }

2. AsyncHandler

// src/utils/asyncHandler.ts import { Request, Response, NextFunction } from "express"; export const asyncHandler = ( fn: (req: Request, res: Response, next: NextFunction) => Promise<void> ) => { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch(next); }; };

3. Error Middleware

// src/middlewares/error.middleware.ts import { Request, Response, NextFunction } from "express"; import { AppError } from "../utils/errors.js"; export const errorHandler = ( err: Error, req: Request, res: Response, next: NextFunction ): void => { console.error("Error:", err); if (err instanceof AppError) { res.status(err.statusCode).json({ success: false, message: err.message, }); return; } res.status(500).json({ success: false, message: "Internal server error", }); };

4. Controller

// src/controllers/auth.controller.ts import { Request, Response } from "express"; import { asyncHandler } from "../utils/asyncHandler.js"; import { ValidationError, UnauthorizedError, ConflictError } from "../utils/errors.js"; import { prisma } from "../lib/prisma.js"; export const register = asyncHandler(async (req: Request, res: Response) => { const { email, password, name } = req.body; if (!email) throw new ValidationError("Email required"); if (!password) throw new ValidationError("Password required"); const existingUser = await prisma.user.findUnique({ where: { email } }); if (existingUser) throw new ConflictError("Email already registered"); const user = await prisma.user.create({ data: { email, password, name }, select: { id: true, email: true, name: true }, }); res.status(201).json({ success: true, data: { user }, }); }); export const login = asyncHandler(async (req: Request, res: Response) => { const { email, password } = req.body; if (!email) throw new ValidationError("Email required"); if (!password) throw new ValidationError("Password required"); const user = await prisma.user.findUnique({ where: { email } }); if (!user) throw new UnauthorizedError("Invalid credentials"); res.status(200).json({ success: true, data: { user }, }); });

5. Server Setup

// src/server.ts import express from "express"; import { authRoutes } from "./routes/index.js"; import { errorHandler } from "./middlewares/error.middleware.js"; const app = express(); const PORT = 4000; // Middleware app.use(express.json()); // Routes app.use("/api/auth", authRoutes); // 404 Handler app.use("*", (req, res) => { res.status(404).json({ success: false, message: `Route ${req.originalUrl} not found`, }); }); // Error Handler (MUST BE LAST) app.use(errorHandler); app.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`); });

Summary: The Evolution

The Journey

Phase 1: Inline responses → App crashes
Phase 2: Try-catch everywhere → Duplicated 50+ times
Phase 3: Throw in try → Still duplicated catch
Phase 4: Custom errors → Better, but still duplicated
Phase 5: Error middleware → Centralized handling
Phase 6: AsyncHandler → Perfect! Clean & maintainable

Before vs After

// ❌ BEFORE: 15 lines per controller export const register = async (req, res) => { try { if (!email) { res.status(400).json({ error: "Email required" }); return; } const user = await prisma.user.create({ data: { email, password } }); res.status(201).json({ user }); } catch (error) { console.error(error); res.status(500).json({ error: "Registration failed" }); } }; // ✅ AFTER: 8 lines per controller export const register = asyncHandler(async (req, res) => { if (!email) throw new ValidationError("Email required"); const user = await prisma.user.create({ data: { email, password } }); res.status(201).json({ user }); });

47% less code, infinitely more maintainable! 🎉

Key Takeaways

Custom error classes provide consistent status codes
Error middleware centralizes error handling logic
AsyncHandler eliminates repetitive try-catch blocks
Clean controllers focus only on business logic
Maintainable codebase with consistent error responses


Happy coding! 🚀

Published: 12/9/2025
Start Call