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 orawaitevery async call - Use
try/catchin 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-errorsor wrapper functions for Express async handlers - Set up
uncaughtExceptionandunhandledRejectionhandlers 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