Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Error Handling & Debugging

Robust error handling is what separates amateur applications from production-ready systems. In this chapter, you’ll learn how to handle errors gracefully, debug effectively, and implement logging strategies.

Understanding Error Types

Operational vs Programming Errors

TypeDescriptionExamplesHandling
OperationalRuntime problems you can anticipateNetwork failures, invalid user input, database downHandle gracefully, retry, inform user
ProgrammingBugs in your codeTypeError, undefined access, wrong API usageFix the code, crash and restart
Critical Distinction: Operational errors should be handled. Programming errors should crash the app (in production, use a process manager to restart).

The Error Class

Every error in JavaScript inherits from the built-in Error class. When you create an error, Node.js automatically captures a stack trace — a breadcrumb trail showing exactly which functions were called and in what order leading up to the error. This stack trace is your most valuable debugging tool.
// Built-in Error types -- each signals a different category of problem
const err1 = new Error('Something went wrong');     // Generic error
const err2 = new TypeError('Expected a string');     // Wrong type used
const err3 = new RangeError('Value out of range');   // Number outside valid range
const err4 = new SyntaxError('Invalid syntax');      // Malformed code or JSON

// Error properties
console.log(err1.message);  // 'Something went wrong' -- human-readable description
console.log(err1.name);     // 'Error' -- the constructor name
console.log(err1.stack);    // Stack trace -- shows the call chain that led here

Custom Error Classes

// Base application error -- this is the foundation of a custom error hierarchy.
// The key insight: by adding statusCode and isOperational, your error handler
// can distinguish "expected" errors (bad user input, missing resource) from
// "unexpected" ones (null pointer, out of memory) and respond appropriately.
class AppError extends Error {
  constructor(message, statusCode, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    // Derive 'fail' (client error, 4xx) vs 'error' (server error, 5xx)
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    // isOperational = true means "we expected this could happen"
    // isOperational = false means "this is a bug in our code"
    this.isOperational = isOperational;
    
    // Exclude this constructor from the stack trace so the trace
    // points to where the error was thrown, not where it was constructed
    Error.captureStackTrace(this, this.constructor);
  }
}

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

class ValidationError extends AppError {
  constructor(message, errors = []) {
    super(message, 400);
    this.errors = errors;
  }
}

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

class ForbiddenError extends AppError {
  constructor(message = 'Forbidden') {
    super(message, 403);
  }
}

class ConflictError extends AppError {
  constructor(message = 'Conflict') {
    super(message, 409);
  }
}

// Usage
throw new NotFoundError('User');
throw new ValidationError('Validation failed', [
  { field: 'email', message: 'Invalid email format' }
]);

Async Error Handling Patterns

Callbacks (Legacy)

// Error-first callback pattern
fs.readFile('file.txt', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  console.log(data);
});

Promises

// With .catch()
fetchData()
  .then(data => process(data))
  .then(result => save(result))
  .catch(err => {
    console.error('Error:', err);
  });

// Promise.allSettled for multiple operations
const results = await Promise.allSettled([
  fetchUser(1),
  fetchUser(2),
  fetchUser(3)
]);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`User ${index + 1}:`, result.value);
  } else {
    console.error(`User ${index + 1} failed:`, result.reason);
  }
});

Async/Await

// Basic try/catch
async function getData() {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      throw new AppError('Failed to fetch data', response.status);
    }
    return await response.json();
  } catch (error) {
    if (error.isOperational) {
      // Handle operational error
      console.error('Operational error:', error.message);
    } else {
      // Re-throw programming errors
      throw error;
    }
  }
}

// Wrapper function for cleaner code -- eliminates the need for try/catch
// in every single route handler. This is a higher-order function: it takes
// your async handler, runs it, and if the returned promise rejects,
// automatically forwards the error to Express's next().
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

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

Express Error Handling

Centralized Error Handler

// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  let error = { ...err };
  error.message = err.message;
  error.stack = err.stack;

  // Log error
  console.error('Error:', {
    message: err.message,
    stack: err.stack,
    url: req.originalUrl,
    method: req.method,
    ip: req.ip,
    userId: req.user?.id
  });

  // Mongoose bad ObjectId
  if (err.name === 'CastError') {
    error = new AppError('Invalid ID format', 400);
  }

  // Mongoose duplicate key
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    error = new ConflictError(`${field} already exists`);
  }

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const errors = Object.values(err.errors).map(e => ({
      field: e.path,
      message: e.message
    }));
    error = new ValidationError('Validation failed', errors);
  }

  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    error = new UnauthorizedError('Invalid token');
  }

  if (err.name === 'TokenExpiredError') {
    error = new UnauthorizedError('Token expired');
  }

  // Development vs Production responses
  if (process.env.NODE_ENV === 'development') {
    res.status(error.statusCode || 500).json({
      success: false,
      error: error.message,
      stack: error.stack,
      errors: error.errors
    });
  } else {
    // Production: Don't leak error details
    if (error.isOperational) {
      res.status(error.statusCode).json({
        success: false,
        error: error.message,
        ...(error.errors && { errors: error.errors })
      });
    } else {
      // Programming error: send generic message
      console.error('UNEXPECTED ERROR:', err);
      res.status(500).json({
        success: false,
        error: 'Something went wrong'
      });
    }
  }
};

module.exports = errorHandler;

404 Handler

// Handle undefined routes
app.use((req, res, next) => {
  next(new NotFoundError(`Route ${req.originalUrl} not found`));
});

// Error handler (must be last)
app.use(errorHandler);

Global Error Handlers

These are your application’s last line of defense — safety nets that catch errors which slipped through every other handler. In a well-designed app, these should rarely fire. If they fire frequently, you have unhandled errors upstream that need fixing. Think of these like the emergency shutoff on a factory machine: you hope they never activate, but when they do, they prevent catastrophic damage.
// Uncaught exceptions (synchronous code) -- fires when a throw statement
// reaches the top of the call stack without being caught.
// The process is in an undefined state after this, so exiting is the only safe option.
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION! Shutting down...');
  console.error(err.name, err.message);
  console.error(err.stack);
  process.exit(1); // Let your process manager (PM2, Docker) restart the app
});

// Unhandled promise rejections -- fires when an async operation rejects
// and no .catch() or try/catch is waiting for it.
// Starting with Node.js 15+, unhandled rejections crash the process by default.
process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION! Shutting down...');
  console.error(reason);
  
  // Close server gracefully (finish handling in-flight requests), then exit
  server.close(() => {
    process.exit(1);
  });
});

// SIGTERM handling -- sent by orchestrators (Heroku, Docker, Kubernetes)
// when they want your process to stop. You get a brief window to finish
// current work before the process is forcefully killed (SIGKILL).
process.on('SIGTERM', () => {
  console.log('SIGTERM received. Shutting down gracefully');
  server.close(() => {
    console.log('Process terminated');
  });
});
Production tip: Always run your Node.js process behind a process manager like PM2 or inside a container orchestrator. When process.exit(1) fires, the manager automatically restarts the process, minimizing downtime. Without a process manager, your app just stays dead after a crash.

Debugging Techniques

Console Methods

// Basic logging
console.log('Info message');
console.error('Error message');
console.warn('Warning message');

// Formatted output
console.table([
  { name: 'John', age: 25 },
  { name: 'Jane', age: 30 }
]);

// Timing
console.time('operation');
// ... some operation
console.timeEnd('operation'); // operation: 123ms

// Stack trace
console.trace('Trace point');

// Assertions
console.assert(1 === 2, 'Values are not equal');

// Grouping
console.group('User Data');
console.log('Name: John');
console.log('Age: 25');
console.groupEnd();

Node.js Debugger

# Built-in debugger
node inspect app.js

# Chrome DevTools
node --inspect app.js
# Then open chrome://inspect in Chrome

# Break on first line
node --inspect-brk app.js

VS Code Debugging

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug App",
      "program": "${workspaceFolder}/src/server.js",
      "env": {
        "NODE_ENV": "development"
      },
      "restart": true,
      "console": "integratedTerminal"
    },
    {
      "type": "node",
      "request": "attach",
      "name": "Attach to Process",
      "port": 9229
    }
  ]
}

Logging with Winston

console.log is fine during development, but in production you need structured, level-based logging that can write to files, external services, or monitoring platforms. Winston is the most widely used logging library in the Node.js ecosystem. It separates the concept of what you log (levels and messages) from where it goes (transports like console, files, or cloud services).
npm install winston
// config/logger.js
const winston = require('winston');
const path = require('path');

const levels = {
  error: 0,
  warn: 1,
  info: 2,
  http: 3,
  debug: 4
};

const colors = {
  error: 'red',
  warn: 'yellow',
  info: 'green',
  http: 'magenta',
  debug: 'blue'
};

winston.addColors(colors);

const format = winston.format.combine(
  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
  winston.format.errors({ stack: true }),
  winston.format.printf(({ timestamp, level, message, stack, ...meta }) => {
    let log = `${timestamp} [${level.toUpperCase()}]: ${message}`;
    if (Object.keys(meta).length) {
      log += ` ${JSON.stringify(meta)}`;
    }
    if (stack) {
      log += `\n${stack}`;
    }
    return log;
  })
);

const transports = [
  // Console output
  new winston.transports.Console({
    format: winston.format.combine(
      winston.format.colorize({ all: true }),
      format
    )
  }),
  
  // Error log file
  new winston.transports.File({
    filename: path.join('logs', 'error.log'),
    level: 'error',
    maxsize: 5242880, // 5MB
    maxFiles: 5
  }),
  
  // Combined log file
  new winston.transports.File({
    filename: path.join('logs', 'combined.log'),
    maxsize: 5242880,
    maxFiles: 5
  })
];

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  levels,
  format,
  transports,
  exitOnError: false
});

module.exports = logger;

HTTP Request Logging with Morgan

const morgan = require('morgan');
const logger = require('./config/logger');

// Stream for Morgan to use Winston
const stream = {
  write: (message) => logger.http(message.trim())
};

// Custom token
morgan.token('body', (req) => JSON.stringify(req.body));

// Use in Express
app.use(morgan(
  ':method :url :status :res[content-length] - :response-time ms',
  { stream }
));

Structured Logging

// Log with context
logger.info('User logged in', {
  userId: user.id,
  email: user.email,
  ip: req.ip,
  userAgent: req.headers['user-agent']
});

logger.error('Database connection failed', {
  host: dbConfig.host,
  error: err.message
});

// Request context middleware
const requestLogger = (req, res, next) => {
  req.logger = logger.child({
    requestId: req.headers['x-request-id'] || uuid(),
    path: req.path,
    method: req.method
  });
  next();
};

// Usage
app.get('/users', (req, res) => {
  req.logger.info('Fetching users');
  // ...
});

Summary

  • Distinguish between operational and programming errors
  • Create custom error classes for different error types
  • Use async wrappers to catch errors in async routes
  • Implement a centralized error handler in Express
  • Handle uncaught exceptions and unhandled rejections
  • Use Winston for structured, configurable logging
  • Debug with VS Code or Chrome DevTools
  • Log meaningful context with each error