Skip to content

Error Handling

Your stack trace in the response is a free info leak. Error handling that doesn't leak is not optional
Default error handlers in Express will tell an attacker your file structure , framework version , and package paths. Every error that reaches the client is intelligence. Structured error responses with consistent formats , sanitized messages , and proper status codes turn a liability into a contract. Handle errors globally. Never trust your route handlers to clean up after themselves

the 4-argument error handler

Express identifies error-handling middleware by its 4 parameters:

app.use((err , req , res , next) => {
  // this only runs when next(err) is called or a handler throws
  console.error(err.stack)
  res.status(500).json({ error: 'Internal server error' })
})

Express checks Function.length of your middleware. If it sees exactly 4 parameters , it treats it as an error handler. This means the parameter names don't matter - only the count matters

must be last in middleware chain

// GOOD - error handler is last
app.use(helmet())
app.use(express.json())
app.use('/api' , routes)
app.use(notFoundHandler)       // 404 handler
app.use(errorHandler)          // 500 handler - LAST

// BAD - error handler before routes
app.use(helmet())
app.use(errorHandler)          // runs before routes - catches nothing
app.use(express.json())
app.use('/api' , routes)

404 handler

Express doesn't have a built-in 404 handler. You write one yourself:

app.use((req , res , next) => {
  res.status(404).json({
    error: 'Not found',
    path: req.path
  })
})

This must be defined AFTER all routes. If a request doesn't match any route , it falls through to this handler

async error wrapping

Here's the big one. Express does NOT catch promise rejections

// THIS CRASHES THE SERVER
app.get('/data' , async (req , res) => {
  const data = await db.query('SELECT * FROM users')
  // if db.query throws , Express doesn't catch it
  // the promise rejection is unhandled
  res.json(data)
})

Fix it with express-async-errors:

npm install express-async-errors
// at the VERY TOP of your entry file - before any routes
require('express-async-errors')

const express = require('express')
// ... rest of your app

This patches Express to catch promise rejections and forward them to your error handler

Or wrap each async handler manually:

const asyncHandler = (fn) => (req , res , next) => {
  Promise.resolve(fn(req , res , next)).catch(next)
}

app.get('/data' , asyncHandler(async (req , res) => {
  const data = await db.query('SELECT * FROM users')
  res.json(data)
}))

The express-async-errors approach is cleaner. Don't forget to install it - you will

custom error classes

// src/utils/errors.js
class AppError extends Error {
  constructor(message , statusCode) {
    super(message)
    this.statusCode = statusCode
    this.isOperational = true  // distinguishes expected errors from bugs
    Error.captureStackTrace(this , this.constructor)
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found` , 404)
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message , 401)
  }
}

class ValidationError extends AppError {
  constructor(errors) {
    super('Validation failed' , 400)
    this.errors = errors
  }
}

module.exports = { AppError , NotFoundError , UnauthorizedError , ValidationError }

Usage in route handlers:

const { NotFoundError } = require('../utils/errors')

app.get('/users/:id' , async (req , res) => {
  const user = await db.findUser(req.params.id)
  if (!user) {
    throw new NotFoundError('User')
  }
  res.json(user)
})

comprehensive error handler

const { AppError } = require('./utils/errors')

app.use((err , req , res , next) => {
  // log the full error for debugging
  console.error(err)

  // set default status
  const statusCode = err.statusCode || 500
  const message = err.isOperational
    ? err.message
    : 'Internal server error'

  // don't leak stack traces in production
  const response = {
    error: message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  }

  // add validation errors if present
  if (err.errors) {
    response.errors = err.errors
  }

  res.status(statusCode).json(response)
})

error handling checklist

// 1. Install express-async-errors at entry point
require('express-async-errors')

// 2. Define custom error classes
// AppError , NotFoundError , ValidationError , etc

// 3. Throw errors in handlers
app.get('/:id' , async (req , res) => {
  const item = await db.findById(req.params.id)
  if (!item) throw new NotFoundError()
  res.json(item)
})

// 4. Global error handler - LAST
app.use((err , req , res , next) => {
  // sanitize , log , respond
})

// 5. 404 handler - BEFORE error handler
app.use((req , res) => {
  res.status(404).json({ error: 'Not found' })
})

prerequisites

express_05_static.md - static file serving , caching , security


next → express_07_templating.md