How do you handle errors in a Node.js application?
Error handling is a critical aspect of building stable and reliable Node.js applications. Node.js's asynchronous, event-driven nature introduces unique challenges, requiring distinct strategies for synchronous and asynchronous errors. This guide outlines common patterns and best practices for managing errors effectively.
1. Synchronous Error Handling (`try...catch`)
For synchronous code, standard JavaScript try...catch blocks are used to intercept exceptions thrown during execution. This is effective for code that runs immediately and completes without involving asynchronous operations.
try {
const result = someSynchronousFunction();
console.log(result);
} catch (error) {
console.error("Synchronous error caught:", error.message);
// Handle the error, e.g., log it, send a response
}
2. Asynchronous Error Handling
2.1. Callback-based Errors (Error-First Callbacks)
In older Node.js APIs and callback-style functions, errors are typically passed as the first argument to the callback function. If an error occurs, the first argument is an Error object; otherwise, it's null or undefined. Always check for an error first.
const fs = require('fs');
fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
if (err) {
console.error("File read error:", err);
return; // Important to return after handling
}
console.log("File content:", data);
});
2.2. Promises (`.catch()`)
Promises provide a cleaner way to handle asynchronous operations and their errors. The .catch() method is used to register a callback that gets invoked when the promise is rejected. It catches errors from all preceding .then() handlers in the chain.
function fetchData() {
return new Promise((resolve, reject) => {
// Simulate an async operation
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve("Data fetched successfully!");
} else {
reject(new Error("Failed to fetch data."));
}
}, 1000);
});
}
fetchData()
.then(data => console.log(data))
.catch(error => console.error("Promise error caught:", error.message));
2.3. Async/Await (`try...catch`)
async/await allows writing asynchronous code that looks and behaves more like synchronous code. This makes standard try...catch blocks effective for handling errors from await expressions, as rejected promises are re-thrown as exceptions.
async function processData() {
try {
const data = await fetchData(); // Assuming fetchData returns a Promise
console.log("Processed data:", data);
} catch (error) {
console.error("Async/await error caught:", error.message);
}
}
processData();
3. Global/Process-Level Error Handling
These handlers are last resorts for catching errors that were not handled elsewhere. While useful, relying solely on them is generally discouraged as they indicate unhandled exceptions and can lead to an unstable application state. It's best practice to log the error and then gracefully shut down the application.
3.1. `process.on('uncaughtException')`
Catches synchronous errors that were not caught by any try...catch block. It's generally advised to log the error and then gracefully shut down the application, as the process might be in an inconsistent state after an uncaught exception.
process.on('uncaughtException', (err) => {
console.error("UNCAUGHT EXCEPTION! Shutting down...");
console.error(err.name, err.message, err.stack);
// Perform synchronous cleanup if necessary (e.g., close DB connections)
process.exit(1); // Exit with a failure code
});
// Example (uncomment to test):
// throw new Error("This is an uncaught synchronous error!");
3.2. `process.on('unhandledRejection')`
Catches Promise rejections that were not handled with a .catch() block. Similar to uncaughtException, it's best to log and consider a graceful shutdown, as unhandled rejections often indicate programming errors.
process.on('unhandledRejection', (reason, promise) => {
console.error("UNHANDLED REJECTION! Shutting down...");
console.error("Reason:", reason);
console.error("Promise:", promise);
// Log the error, perform cleanup, and exit
process.exit(1);
});
// Example (uncomment to test):
// Promise.reject(new Error("This is an unhandled promise rejection!"));
4. Express.js Error Handling Middleware
In Express.js applications, errors can be handled by special error-handling middleware functions that take four arguments: (err, req, res, next). These are typically defined after all other routes and middleware to ensure they catch errors from the entire request pipeline.
const express = require('express');
const app = express();
app.get('/error', (req, res, next) => {
// Simulate an error
const error = new Error("Something went wrong!");
error.statusCode = 500;
next(error); // Pass the error to the next middleware
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack); // Log the error stack
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
status: 'error',
message: err.message || 'Internal Server Error'
});
});
// Uncomment to run server:
// app.listen(3000, () => console.log('Server running on port 3000'));
5. Custom Error Classes
Creating custom error classes allows for more specific error identification and handling logic. You can extend the built-in Error class to add properties like statusCode, status, or isOperational, making it easier to categorize and respond to different types of errors.
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true; // Mark as an error expected to be handled
// Capture the stack trace, excluding the constructor call itself
Error.captureStackTrace(this, this.constructor);
}
}
// Usage example:
// throw new AppError('Invalid input data', 400);
6. Best Practices
- Log Errors Thoroughly: Use a robust logging solution (e.g., Winston, Pino) to record error details, including stack traces, request data, and user information. This is crucial for debugging and monitoring.
- Distinguish Operational vs. Programmer Errors: Operational errors (e.g., network timeout, invalid user input, file not found) are expected and can be handled gracefully. Programmer errors (e.g., trying to read a property of
undefined) indicate bugs and should typically lead to application restart to prevent further corruption. - Avoid Catching All Errors Globally Without Specific Actions: While global handlers are useful, they should primarily log and then initiate a graceful shutdown for critical errors, rather than attempting to continue operation in an unknown state.
- Graceful Shutdown: On encountering critical errors (especially programmer errors caught by
uncaughtExceptionorunhandledRejection), perform necessary cleanup (e.g., close database connections, release resources) and then exit the process, potentially allowing a process manager (like PM2 or Kubernetes) to restart the application. - Send Meaningful Responses: For web applications, return appropriate HTTP status codes and clear, but not overly verbose, error messages to the client. Avoid exposing sensitive internal error details to users.