Skip to content

async_04 - Error Handling

Errors in async code don't behave like errors in synchronous code
A synchronous function throws , the call stack unwinds , and your try/catch catches it. An async function throws into a Promise rejection , and if nobody calls .catch() or await it, the error floats into the void until Node decides it's done with your process. Understanding error handling in Node means understanding the difference between operational errors and programmer errors , and knowing which tool to use for each

error types

operational errors

These are expected failures - the file doesn't exist , the database connection dropped , the API returned a 500. Operational errors are part of normal runtime. You can't prevent them but you should handle them gracefully

const fs = require('fs/promises')

async function readUserConfig(userId) {
  try {
    const data = await fs.readFile(`/home/${userId}/config.json`, 'utf8')
    return JSON.parse(data)
  } catch (err) {
    if (err.code === 'ENOENT') {
      // File not found - operational , expected
      return defaultConfig(userId)
    }
    if (err instanceof SyntaxError) {
      // JSON parse failed - operational , bad config file
      console.error(`config file for ${userId} has invalid JSON`)
      return defaultConfig(userId)
    }
    // Unknown error - re-throw
    throw err
  }
}

programmer errors

These are bugs - passing a string instead of a number , calling undefined() , accessing a property on null. Programmer errors should crash the process because the application is in an undefined state

// Programmer error - crash immediately
app.get('/user/:id', (req, res) => {
  const data = undefined
  res.json(data.someProperty) // TypeError: Cannot read properties of undefined
  // The process should crash - your code has a bug
})

Rule: Operational errors = handle them. Programmer errors = crash and restart (let the process manager handle recovery)

try/catch with async functions

try/catch works with async/await because rejected Promises behave like thrown exceptions

async function fetchData(url) {
  try {
    const response = await fetch(url)
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
    }
    return await response.json()
  } catch (err) {
    // Catches both network errors AND HTTP error responses
    console.error('fetch failed:', url, err.message)
    throw err
  }
}

Multiple awaits in one try/catch block:

async function processOrder(orderId) {
  try {
    const order = await db.findOrder(orderId)
    const user = await db.findUser(order.userId)
    const inventory = await db.checkInventory(order.items)

    if (!inventory.available) {
      throw new Error('insufficient inventory')
    }

    await db.updateOrderStatus(orderId, 'confirmed')
    return { order, user, inventory }
  } catch (err) {
    // Catches errors from any of the above operations
    console.error('order processing failed:', orderId, err.message)
    await db.logError(orderId, err)
    throw err
  }
}

The entire sequence is atomic from an error perspective - if any step fails , the catch block runs

error propagation in Express with async

Express async errors need explicit handling
Without wrapping, async errors are swallowed and Node crashes

// Production error handling middleware setup
function errorHandler(err, req, res, next) {
  // Log the full error for debugging
  console.error(`${new Date().toISOString()} [ERROR]`, err)

  // Don't leak internal error details to the client
  res.status(err.statusCode || 500).json({
    error: {
      message: err.isOperational ? err.message : 'Internal server error',
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
    },
  })
}

app.use(errorHandler)

process-level error events

process.on('uncaughtException')

Fires when a synchronous error bubbles all the way up to the event loop without being caught

process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION:', err)

  // Log to external monitoring
  logErrorToService(err)

  // Attempt graceful shutdown
  server.close(() => {
    process.exit(1)
  })

  // Force exit if graceful shutdown hangs
  setTimeout(() => process.exit(1), 10000).unref()
})

// This triggers uncaughtException - no try/catch around it
setTimeout(() => {
  throw new Error('this was not caught')
}, 1000)

DO NOT use this for normal error handling. It's a last resort to log and shut down cleanly. The application state is unreliable after an uncaught exception - file handles could be in inconsistent states , database connections might be half-closed. Restart the process instead of trying to recover

process.on('unhandledRejection')

Fires when a Promise rejects without a .catch() or await

process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION:', reason)

  // In Node 15+ , this will eventually crash the process
  // Don't try to recover - just log and exit
  process.exit(1)
})

In Node 17+ , unhandled rejections crash the process by default
The --unhandled-rejections=strict flag (default from Node 15) makes this a fatal error. You cannot use this handler to silently swallow rejections anymore

process.on('warning')

For deprecation warnings and custom warnings - not errors but useful for proactive debugging

process.on('warning', (warning) => {
  if (warning.name === 'DeprecationWarning') {
    console.error('DEPRECATION:', warning.message, warning.stack)
  }
})

custom error classes

Extend the Error class to add semantics and status codes

class HttpError extends Error {
  constructor(statusCode, message) {
    super(message)
    this.name = 'HttpError'
    this.statusCode = statusCode
    this.isOperational = true
  }
}

class NotFoundError extends HttpError {
  constructor(resource, id) {
    super(404, `${resource} with id ${id} not found`)
    this.name = 'NotFoundError'
    this.resource = resource
    this.resourceId = id
  }
}

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

class AuthError extends HttpError {
  constructor(message = 'Authentication required') {
    super(401, message)
    this.name = 'AuthError'
  }
}

// Usage
async function getUser(id) {
  const user = await db.findUser(id)
  if (!user) {
    throw new NotFoundError('User', id)
  }
  return user
}

async function createUser(data) {
  const errors = []

  if (!data.email) errors.push('email is required')
  if (!data.password) errors.push('password is required')
  if (data.password && data.password.length < 8) errors.push('password too short')

  if (errors.length > 0) {
    throw new ValidationError(errors)
  }

  return db.createUser(data)
}

Error middleware that uses custom classes:

app.use((err, req, res, next) => {
  // Default to 500 for unexpected errors
  const statusCode = err.statusCode || 500
  const message = err.isOperational
    ? err.message
    : 'Internal server error'

  // Log programmer errors for debugging
  if (!err.isOperational) {
    console.error('PROGRAMMER ERROR:', err)
  }

  res.status(statusCode).json({
    error: {
      message,
      ...(err.errors && { details: err.errors }),
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
    },
  })
})

proper error logging without leaking secrets

Node errors contain stack traces with absolute file paths , variable values at each call frame , and environment information. Sending this to the client is a security vulnerability that tells attackers exactly where your code lives and what it's doing

// BAD - leaks internal paths and source structure
console.error(err.stack)

// GOOD - log the message , sanitize sensitive content
function sanitizeError(err) {
  const safe = {
    message: err.message,
    type: err.name,
    code: err.code,
    statusCode: err.statusCode,
    timestamp: new Date().toISOString(),
    requestId: err.requestId, // correlation ID for tracing
  }

  // Don't include stack in production
  if (process.env.NODE_ENV === 'development') {
    safe.stack = err.stack
  }

  return safe
}

// Log to structured logging system (not console.log in production)
// Use libraries like pino , winston , or bunyan
const logger = require('pino')({
  level: process.env.LOG_LEVEL || 'info',
  redact: ['req.headers.authorization', 'req.body.password'],
})

logger.error({ err }, 'operation failed')

error handling checklist

  • Always check the error parameter in callback-based APIs
  • Always add .catch() to every Promise chain or await every async call
  • Use try/catch in async functions - don't let rejections propagate unhandled
  • Differentiate operational errors (handle) from programmer errors (crash)
  • Create custom error classes with status codes and structured data
  • Never leak stack traces to production clients
  • Log errors with structured logging , not console.error
  • Use express-async-errors or wrapper functions for Express async handlers
  • Set up uncaughtException and unhandledRejection handlers for graceful shutdown
  • Test your error paths - not just the happy path

prerequisites

async_03_async_await.md - async/await syntax and patterns

next -> mod_01_modules.md