Skip to main content

Command Palette

Search for a command to run...

Writing Better APIs: Error Handling and Logging That Actually Works

Published
9 min read
Writing Better APIs: Error Handling and Logging That Actually Works

You know that moment when your API throws a 500 error and you have no idea why? Or when you're hunting through 47 route files trying to find where that specific error message comes from? Let's fix that.

The Copy-Paste Problem

Most Express.js codebases look like this:

app.get('/users/:id', async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json(user);
  } catch (error) {
    console.log(error);
    res.status(500).json({ error: 'Something went wrong' });
  }
});

app.post('/users', async (req, res) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json(user);
  } catch (error) {
    console.log(error);
    res.status(500).json({ error: 'Something went wrong' });
  }
});

// ... 45 more routes with identical try-catch blocks

Every route has the same error handling code. Every route logs errors differently. When production breaks, you're searching through console.log statements hoping to find something useful.

The Async Wrapper Pattern

Here's a tiny function that changes everything:

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

This wrapper catches any error from your async route handlers and passes them to Express's error handling middleware. Now your routes look like this:

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

app.post('/users', asyncHandler(async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json(user);
}));

No try-catch blocks. Just throw errors when something goes wrong. The wrapper handles everything.

Centralized Error Handling

Now that errors flow to one place, you need middleware to catch them properly:

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(message, 404);
  }
}

class ValidationError extends AppError {
  constructor(message = 'Validation failed') {
    super(message, 400);
  }
}

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

The isOperational flag is crucial. It separates expected errors (user not found, validation failed) from unexpected errors (database connection lost, null reference).

Here's the error handler middleware:

app.use((err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';

  if (process.env.NODE_ENV === 'development') {
    res.status(err.statusCode).json({
      status: err.status,
      error: err,
      message: err.message,
      stack: err.stack
    });
  } else {
    // Production: don't leak error details
    if (err.isOperational) {
      res.status(err.statusCode).json({
        status: err.status,
        message: err.message
      });
    } else {
      // Programming error: log and hide details
      console.error('ERROR 💥', err);
      res.status(500).json({
        status: 'error',
        message: 'Something went wrong'
      });
    }
  }
});

In development, you get full stack traces. In production, users see clean error messages while unexpected errors are hidden and logged for investigation.

Why This Pattern Works

Operational errors are part of normal application flow. A user requests a resource that doesn't exist? That's operational. Invalid input? Operational. These errors should be handled gracefully and returned to the user.

Programming errors are bugs in your code. Trying to read a property of undefined? Programming error. Database connection string is wrong? Programming error. These should never be shown to users.

The isOperational flag lets you handle both correctly in production.

Logging That Actually Helps

console.log(error) tells you something broke. It doesn't tell you which user triggered it, what they were trying to do, or how to reproduce it.

Here's a proper request logger:

const requestLogger = (req, res, next) => {
  const start = Date.now();
  
  // Capture the original end function
  const originalEnd = res.end;
  
  res.end = function(...args) {
    const duration = Date.now() - start;
    
    console.log(JSON.stringify({
      timestamp: new Date().toISOString(),
      method: req.method,
      url: req.url,
      status: res.statusCode,
      duration: `${duration}ms`,
      userAgent: req.get('user-agent'),
      ip: req.ip,
      userId: req.user?.id || 'anonymous',
      // Add correlation ID for tracking across services
      correlationId: req.headers['x-correlation-id'] || generateId()
    }));
    
    // Call the original end function
    return originalEnd.apply(this, args);
  };
  
  next();
};

app.use(requestLogger);

This logs every request with context. Now when something breaks, you know who did what and how long it took.

Structured Logging for Production

JSON logs are searchable. String concatenation is not.

// Bad: impossible to search or filter
console.log('User [email protected] failed login attempt from 192.168.1.1');

// Good: structured and queryable
logger.warn({
  event: 'login_failed',
  email: '[email protected]',
  ip: '192.168.1.1',
  timestamp: new Date().toISOString(),
  attemptCount: 3
});

Use a logging library like Winston or Pino:

const winston = require('winston');

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ 
      filename: 'error.log', 
      level: 'error' 
    }),
    new winston.transports.File({ 
      filename: 'combined.log' 
    })
  ]
});

// Console logging for development
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }));
}

module.exports = logger;

Now you can search your logs by event type, user ID, status code, or any field you include.

Error Logging with Context

When an error happens, log everything you need to debug it:

app.use((err, req, res, next) => {
  const errorLog = {
    timestamp: new Date().toISOString(),
    error: {
      message: err.message,
      stack: err.stack,
      statusCode: err.statusCode
    },
    request: {
      method: req.method,
      url: req.url,
      headers: req.headers,
      body: req.body,
      params: req.params,
      query: req.query,
      userId: req.user?.id
    }
  };

  if (err.isOperational) {
    logger.warn(errorLog);
  } else {
    logger.error(errorLog);
  }

  // Send response
  if (process.env.NODE_ENV === 'production') {
    if (err.isOperational) {
      res.status(err.statusCode).json({
        status: err.status,
        message: err.message
      });
    } else {
      res.status(500).json({
        status: 'error',
        message: 'Something went wrong'
      });
    }
  } else {
    res.status(err.statusCode).json({
      status: err.status,
      error: err,
      message: err.message,
      stack: err.stack
    });
  }
});

This logs the full context of failed requests. When debugging, you can see exactly what the user sent and what went wrong.

Monitoring Real Performance

Logging tells you what happened after the fact. Monitoring tells you what's happening right now.

Track key metrics in your routes:

const metrics = {
  requestCount: 0,
  errorCount: 0,
  slowRequestCount: 0,
  totalResponseTime: 0
};

const monitoringMiddleware = (req, res, next) => {
  const start = Date.now();
  metrics.requestCount++;

  res.on('finish', () => {
    const duration = Date.now() - start;
    metrics.totalResponseTime += duration;

    if (duration > 1000) {
      metrics.slowRequestCount++;
      logger.warn({
        event: 'slow_request',
        duration,
        method: req.method,
        url: req.url,
        userId: req.user?.id
      });
    }

    if (res.statusCode >= 500) {
      metrics.errorCount++;
    }
  });

  next();
};

// Endpoint to expose metrics
app.get('/metrics', (req, res) => {
  const avgResponseTime = metrics.totalResponseTime / metrics.requestCount;
  const errorRate = (metrics.errorCount / metrics.requestCount) * 100;
  
  res.json({
    requestCount: metrics.requestCount,
    errorCount: metrics.errorCount,
    errorRate: `${errorRate.toFixed(2)}%`,
    slowRequestCount: metrics.slowRequestCount,
    avgResponseTime: `${avgResponseTime.toFixed(2)}ms`
  });
});

Now you can track error rates, response times, and slow requests. Hook this up to Prometheus, Datadog, or any monitoring service.

Alert on What Matters

Not every error needs an alert. A single 404? Normal. A 50% error rate? Critical.

const alerting = {
  errorThreshold: 100, // Alert after 100 errors
  errorWindow: 60000,  // in 1 minute
  errors: []
};

const checkAlertThreshold = () => {
  const now = Date.now();
  const recentErrors = alerting.errors.filter(
    timestamp => now - timestamp < alerting.errorWindow
  );
  
  alerting.errors = recentErrors;

  if (recentErrors.length >= alerting.errorThreshold) {
    logger.error({
      event: 'error_threshold_exceeded',
      errorCount: recentErrors.length,
      timeWindow: `${alerting.errorWindow / 1000}s`,
      message: 'High error rate detected - investigate immediately'
    });
    
    // Send alert to your monitoring service
    // sendSlackAlert(), sendPagerDutyAlert(), etc.
    
    // Reset to avoid spam
    alerting.errors = [];
  }
};

app.use((err, req, res, next) => {
  // Track errors for alerting
  if (!err.isOperational) {
    alerting.errors.push(Date.now());
    checkAlertThreshold();
  }

  // ... rest of error handling
});

This alerts when error rates spike, not on every single error.

Complete Setup

Here's what a complete setup looks like:

const express = require('express');
const winston = require('winston');

const app = express();

// Logger setup
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

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

// Custom errors
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
  }
}

// Request logger
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({
      method: req.method,
      url: req.url,
      status: res.statusCode,
      duration: Date.now() - start,
      ip: req.ip
    });
  });
  next();
});

// Your routes
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new AppError('User not found', 404);
  res.json(user);
}));

// Error handler (must be last)
app.use((err, req, res, next) => {
  const errorLog = {
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    userId: req.user?.id
  };

  if (err.isOperational) {
    logger.warn(errorLog);
    res.status(err.statusCode).json({
      status: 'fail',
      message: err.message
    });
  } else {
    logger.error(errorLog);
    res.status(500).json({
      status: 'error',
      message: 'Something went wrong'
    });
  }
});

app.listen(3000);

Clean routes, centralized error handling, structured logging, and proper monitoring. This is how production APIs should work.

Key Takeaways

Error handling:

  • Use async wrappers to eliminate try-catch boilerplate

  • Centralize error handling in one middleware

  • Distinguish operational errors from programming errors

  • Never leak error details in production

Logging and monitoring:

  • Log structured JSON, not string messages

  • Include context with every log (user, URL, timestamp)

  • Track metrics like error rates and response times

  • Alert on patterns, not individual errors

The async wrapper pattern alone cuts hundreds of lines of repetitive code. Combined with proper logging, you go from hunting through console statements to querying structured logs. Your debugging time drops from hours to minutes.

Stop copy-pasting try-catch blocks. Stop using console.log. Start building APIs that are actually maintainable.


TLDR;

Use an async wrapper to remove try-catch blocks from every route. Create centralized error handling middleware with custom error classes that distinguish operational errors (expected) from programming errors (bugs). Implement structured JSON logging with Winston or Pino instead of console.log. Track metrics like error rates, response times, and slow requests. Alert on error rate spikes, not individual errors. This approach reduces boilerplate by 70%+, makes debugging dramatically faster, and gives you production visibility you can actually use.

Better API Error Handling & Logging in Express.js