Skip to content

Error Handling Middleware

Unhandled errors in route handlers crash your server or return confusing responses. Express has a built-in error handling mechanism that centralizes all error responses in one place.

Express identifies error handling middleware by its 4-argument signature: (err, req, res, next):

import { Request, Response, NextFunction } from 'express'
function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
console.error(err.stack)
res.status(500).json({ error: 'Internal server error' })
}
// Must be registered LAST — after all routes and other middleware
app.use(errorHandler)

Call next(error) from any route handler or middleware to skip to the error handler:

app.get('/posts/:id', (req, res, next) => {
try {
const post = db.prepare('SELECT * FROM posts WHERE id = ?')
.get(parseInt(req.params.id))
if (!post) return res.status(404).json({ error: 'Not found' })
res.json(post)
} catch (err) {
next(err) // passes to the error handler
}
})

Create a custom error class to carry HTTP status codes:

src/errors.ts
export class AppError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message)
this.name = 'AppError'
}
}

Throw it from route handlers:

import { AppError } from '../errors.js'
app.get('/posts/:id', (req, res, next) => {
try {
const post = findPost(req.params.id)
if (!post) throw new AppError(404, 'Post not found')
res.json(post)
} catch (err) {
next(err)
}
})

Update the error handler to handle both AppError and unknown errors:

src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
import { AppError } from '../errors.js'
export function errorHandler(
err: unknown,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message })
}
console.error('Unexpected error:', err)
res.status(500).json({ error: 'Internal server error' })
}

Express 4 doesn’t automatically catch async errors. You must either:

Option 1: try/catch + next(err):

router.post('/posts', async (req, res, next) => {
try {
const post = await createPost(req.body)
res.status(201).json(post)
} catch (err) {
next(err)
}
})

Option 2: Async wrapper (cleaner):

src/utils/asyncHandler.ts
import { Request, Response, NextFunction, RequestHandler } from 'express'
export function asyncHandler(fn: RequestHandler): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}
router.post('/posts', asyncHandler(async (req, res) => {
const post = await createPost(req.body) // errors caught automatically
res.status(201).json(post)
}))

The Bulletin API uses better-sqlite3 which is synchronous, so async error handling is less critical — but asyncHandler is still good practice.

  1. Create src/errors.ts with the AppError class.
  2. Create src/middleware/errorHandler.ts with the error handler.
  3. Register it in src/index.ts after all routes.
  4. Throw an AppError(404, 'Post not found') from a route and verify you get the correct JSON response.
  • Error middleware has 4 arguments: (err, req, res, next) — Express identifies it by arity.
  • Must be registered last — after all routes and non-error middleware.
  • next(error) from any handler jumps to the error middleware.
  • A custom AppError class carries a statusCode for clean, typed error responses.
  • Wrap async route handlers to automatically catch Promise rejections.