CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/express-error-handling

Production error handling for Express APIs — error middleware, async wrappers,

89

2.02x
Quality

86%

Does it follow best practices?

Impact

97%

2.02x

Average score across 4 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/express-error-handling/

name:
express-error-handling
description:
Production error handling patterns that must be included from day one in any Express API. Apply whenever building, extending, or scaffolding an Express backend -- even if the task only asks for routes or CRUD endpoints. These patterns prevent silent async crashes, inconsistent error formats, stack trace leaks, and dropped connections on deploy.
keywords:
express, express api, rest api, api routes, crud, express server, express typescript, express app, express backend, express middleware, express routes, node api, node server, build api, create api, new api, api endpoint, route handler, express setup, express scaffold
license:
MIT

Express Error Handling

Production-grade error handling that belongs in every Express application from day one. When building any Express API -- whether a quick prototype or a production service -- always include these patterns without being asked.


Why This Matters From Day One

Error handling is not a "harden later" task. Without it from the start:

  • Async routes silently crash -- Express 4 does not catch rejected promises from async handlers. One unhandled rejection and your process dies.
  • Clients get garbage -- Raw stack traces, database constraint strings (SQLITE_CONSTRAINT: UNIQUE constraint failed), or HTML error pages instead of JSON.
  • Every route invents its own format -- One route returns { error: "..." }, another returns { message: "..." }, a third returns a bare string. Mobile clients cannot parse errors reliably.
  • Deploys drop connections -- Without graceful shutdown, a SIGTERM from Kubernetes kills in-flight requests instantly.

These are not edge cases. They are the first things that break in production.


The Patterns

1. Custom Error Classes

Define a base error class and common subtypes so routes throw typed errors with status codes:

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

export class NotFoundError extends AppError {
  constructor(resource: string, id?: string) {
    super(404, id ? `${resource} ${id} not found` : `${resource} not found`, 'NOT_FOUND');
  }
}

export class ValidationError extends AppError {
  constructor(message: string, public details?: { field: string; message: string }[]) {
    super(400, message, 'VALIDATION_ERROR');
  }
}

2. Async Handler Wrapper

Express 4 does not forward rejected promises to error middleware. Wrap every async route:

// src/middleware/async-handler.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';

export function asyncHandler(
  fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
): RequestHandler {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

Use it on every async route:

router.get('/api/books/:id', asyncHandler(async (req, res) => {
  const book = await getBook(req.params.id);
  if (!book) throw new NotFoundError('Book', req.params.id);
  res.json({ data: book });
}));

3. Global Error Middleware (Must Be Last)

A single error handler with 4 parameters that all errors flow through:

// src/middleware/error-handler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors';

export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
  console.error(`[${req.method} ${req.path}]`, err.message);
  if (!(err instanceof AppError)) {
    console.error(err.stack);
  }

  if (err instanceof AppError) {
    const body: any = { error: { code: err.code, message: err.message } };
    if ('details' in err && (err as any).details) {
      body.error.details = (err as any).details;
    }
    res.status(err.statusCode).json(body);
    return;
  }

  // Unknown error -- never leak internals
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
    }
  });
}

Register it after all routes:

app.use('/api', routes);

// 404 catch-all for unknown API routes (before error middleware)
app.use('/api', (_req, res) => {
  res.status(404).json({
    error: { code: 'NOT_FOUND', message: 'Endpoint not found' }
  });
});

app.use(errorHandler); // Must be last

4. Graceful Shutdown

const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

function shutdown(signal: string) {
  console.log(`${signal} received. Shutting down gracefully...`);
  server.close(() => {
    console.log('HTTP server closed');
    process.exit(0);
  });

  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10_000);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Error Response Format

All errors must follow this shape:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body"
  }
}

For validation errors with field-level detail:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "details": [
      { "field": "email", "message": "must be a valid email address" },
      { "field": "name", "message": "is required" }
    ]
  }
}

Never:

  • Return raw error messages from libraries
  • Return stack traces in production
  • Return different shapes from different routes
  • Return errors as 200 responses

Common Patterns

Handling External Service Errors

router.get('/api/weather/:city', asyncHandler(async (req, res) => {
  try {
    const data = await fetchWeather(req.params.city);
    res.json({ data });
  } catch (err: any) {
    if (err.message === 'ETIMEDOUT') {
      throw new AppError(504, 'Weather service timed out', 'UPSTREAM_TIMEOUT');
    }
    if (err.status === 503) {
      throw new AppError(502, 'Weather service is unavailable', 'UPSTREAM_UNAVAILABLE');
    }
    throw err;
  }
}));

Database Constraint Errors

router.post('/api/users', asyncHandler(async (req, res) => {
  try {
    const user = await createUser(req.body);
    res.status(201).json({ data: user });
  } catch (err: any) {
    if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
      throw new AppError(409, 'Email already exists', 'DUPLICATE');
    }
    throw err;
  }
}));

Validation with Field Details

router.post('/api/orders', asyncHandler(async (req, res) => {
  const errors: { field: string; message: string }[] = [];
  const { customer_name, items } = req.body;

  if (!customer_name?.trim()) {
    errors.push({ field: 'customer_name', message: 'is required' });
  }
  if (!Array.isArray(items) || items.length === 0) {
    errors.push({ field: 'items', message: 'must be a non-empty array' });
  }
  if (errors.length > 0) {
    throw new ValidationError('Invalid request body', errors);
  }

  const order = await createOrder({ customer_name: customer_name.trim(), items });
  res.status(201).json({ data: order });
}));

Checklist

Every Express app must have from the start:

  • Custom error class hierarchy (AppError base + subtypes)
  • Async handler wrapper for all async routes
  • Global error middleware (4 args) registered last
  • 404 catch-all for unknown routes
  • Consistent error response format: { error: { code, message } }
  • No stack traces or raw error messages leaked to clients
  • Graceful shutdown on SIGTERM/SIGINT
  • Appropriate HTTP status codes for each error type

skills

express-error-handling

tile.json