
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:
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
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
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
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
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
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! 🚀