5 Error handling best practices for Node.js apps

Mayank C
Tech Tonic

--

Error handling is an essential aspect of software development that ensures the reliability and stability of applications. In the universe of Node.js, errors can arise from various sources, including user input, database queries, API calls, and more. If not handled properly, these errors can lead to unexpected crashes, data corruption, and compromised security.

Proper error handling is crucial for maintaining a robust and fault-tolerant application. By catching and responding to errors, developers can prevent unexpected behavior, minimize downtime, and provide users with meaningful feedback. Effective error handling also helps in identifying and debugging issues more efficiently, reducing the overall development time and improving the overall quality of the codebase.

In Node.js, error handling is made possible by the language’s built-in support for asynchronous programming, promises, and callbacks. However, without a structured approach to error handling, applications can quickly become prone to errors and crashes.

To overcome these challenges, we’ll explore five best practices for error handling in Node.js. These best practices will help developers build more resilient, reliable, and maintainable applications that minimize the impact of errors on users.

In this article, we’ll learn about:

  • Try-catch blocks: How to use try-catch blocks to catch and handle errors as they occur.
  • Async/await with error handling: Best practices for working with asynchronous code and handling promises that may reject.
  • Logging errors and warnings: Why logging is essential for error tracking and analysis.
  • Implementing a global error handler: How to set up a global error handler to catch unhandled exceptions.
  • Providing meaningful error messages: Techniques for crafting informative error messages that help users understand what went wrong.

Let’s learn these in detail.

Best practice 1 — Try/catch blocks

The basic syntax of a try-catch block is straightforward:

try { code } catch (error) { errorHandlingCode }

The code within the try block is executed, and if an exception occurs, the code in the catch block is executed. This structure allows developers to isolate critical sections of code, allowing for targeted error handling.

The try block contains the code that may throw an exception. This section should be as small as possible, as excessive code within the try block can lead to performance issues and make it more challenging to identify the source of errors.

try {
const data = fs.readFileSync('nonexistentfile.txt');
} catch (err) {
console.error(`Error reading file: ${err.message}`);
}

In this example, the fs.readFileSync function is called within the try block. If an error occurs, such as when trying to read a non-existent file, the code in the catch block is executed.

The catch block contains the code that handles exceptions thrown by the try block. This section should be designed to handle specific types of errors and provide meaningful feedback to users.

try {
const data = fs.readFileSync('nonexistentfile.txt');
} catch (err) {
if (err.code === 'ENOENT') {
console.error(`Error reading file: File not found.`);
} else {
console.error(`Unexpected error: ${err.message}`);
}
}

In this example, the catch block checks the code property of the error object to determine the specific type of exception. If it’s an “ENOENT” error (file not found), a more informative message is logged. Otherwise, a generic error message is displayed.

Here are some best practices in using try/catch blocks:

  1. Specificity: Be specific with error types and variables in the catch clause. This allows for targeted error handling and reduces the risk of catching unrelated exceptions.
  2. Small try blocks: Keep try blocks as small as possible to minimize performance overhead.
  3. Meaningful feedback: Provide users with meaningful feedback by including informative error messages.
  4. Exception hierarchy: Establish a clear exception hierarchy to ensure that specific errors are handled before more general ones.

Best practice 2 — Async/await with error handling

Async/await is a syntax sugar on top of promises that simplifies the writing of asynchronous code. It allows developers to write asynchronous code that looks and feels synchronous, making it easier to read and maintain. Additionally, async/await provides better support for error handling, as errors are automatically propagated through the promise chain.

When using async/await with error handling, there are a few key concepts to keep in mind:

  1. Error Propagation: When an error occurs within an async function, it is automatically propagated up the promise chain and can be caught by the outer try-catch block.
  2. Try-Catch Blocks: Try-catch blocks should be used to catch errors that occur within async functions. The inner try block should contain the code that may throw an error, while the outer try block contains the code that handles the error.
async function fetchData() {
try {
const response = await db.query('SELECT * FROM users');
return response;
} catch (err) {
console.error(`Database query error: ${err.message}`);
throw err; // Re-throw the error to be caught by the outer try-catch block
}
}

async function main() {
try {
const data = await fetchData();
console.log(data);
} catch (err) {
console.error('Error occurred:', err);
}
}

In this example, the fetchData function contains an inner try block that calls the db.query function. If an error occurs, it is caught by the outer try-catch block and logged to the console.

Here are some best practices in using async/await with error handling:

  1. Re-Throwing Errors: When catching errors within an async function, it is often necessary to re-throw the error so that it can be handled by the outer try-catch block.
  2. Specific Error Types: Be specific with error types and variables in the catch clause. This allows for targeted error handling and reduces the risk of catching unrelated exceptions.
  3. Meaningful Feedback: Provide users with meaningful feedback by including informative error messages.

In addition to re-throwing errors, developers should also consider using Promise.race() or Promise.all() to handle errors in concurrent asynchronous code. These methods allow developers to catch errors as soon as they occur, rather than waiting for the promise to resolve or reject.

const fetchPromises = [
db.query('SELECT * FROM users'),
db.query('SELECT * FROM products')
];

Promise.race(fetchPromises).then(data => {
console.log(data);
}).catch(err => {
console.error('Error occurred:', err);
});

In this example, Promise.race() is used to catch errors as soon as they occur. This can be particularly useful in scenarios where multiple promises are made concurrently and error handling needs to be optimized for performance.

Best practice 3 — Logging errors and warnings

Logging errors and warnings serves several purposes:

  1. Error Tracking: Logging helps track issues that occur during development, testing, or production, making it easier to diagnose problems and identify areas for improvement.
  2. Debugging: By capturing error messages and warning logs, developers can debug applications more efficiently, reducing the time spent on resolving issues.
  3. Auditing: Logs can be used to audit application activity, ensuring that sensitive data is protected and compliance requirements are met.

In Node.js, there are several types of logs that should be captured:

  1. Error Logs: Errors occur when an unexpected condition occurs, such as a runtime error or an exception.
  2. Warning Logs: Warnings indicate potential issues or problematic conditions that do not necessarily prevent the application from functioning but may require attention.

To implement effective logging in Node.js:

  1. Use Built-in Logging Modules: The built-in console module and the popular winston library provide robust logging capabilities.
  2. Log at Various Levels: Log errors, warnings, and other messages at different levels to capture specific information.
const logger = require('winston');

// Create a logger instance with various log level options
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(info => `${info.timestamp} - ${info.level}: ${info.message}`)
),
handleErrors: true
}));

// Log errors and warnings
console.error('Error occurred:', new Error('Test error'));
logger.warn('Potential issue detected');

The logging configuration should be tailored to the specific needs of your application:

  1. Customize Format: Use a format that suits your logging needs, such as adding timestamps or log levels.
  2. Set Log Levels: Configure log levels (e.g., debug, info, warn, error) based on your application’s requirements.
const logger = require('winston');

// Create a logger instance with customized configuration
const config = {
level: 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(info => `${info.timestamp} - ${info.level}: ${info.message}`)
),
transports: [
new winston.transports.Console()
]
};

// Create a logger instance with the customized configuration
logger.configure(config);

In addition to logging errors and warnings, consider implementing:

  1. Rate Limiting: Limit the frequency of log messages to prevent overwhelming your application or logging system.
  2. Log Retention: Configure your logging system to retain logs for a specified period.
// Enable rate limiting with a 5-second window
logger.add(new winston.transports.Console({
rateLimit: {
interval: 5000,
messagesPerInterval: 10
}
}));

In addition to customizing logging configurations, consider using third-party libraries like winston or log4js, which provide robust logging capabilities out-of-the-box.

Best practice 4 — Implementing a global error handler

A global error handler provides several benefits:

  1. Centralized Error Handling: A single point of failure is established, making it easier to manage and maintain error handling across the application.
  2. Consistent Error Handling: All unhandled errors are consistently handled, reducing the risk of crashes or data loss.
  3. Improved Application Reliability: By ensuring that all unhandled errors are addressed, the overall reliability and stability of the application are improved.

To implement an effective global error handler in Node.js:

  1. Use the UncaughtException Event: The uncaughtException event is triggered when an unhandled exception occurs.
  2. Catch and Handle Errors: Create a function that catches errors using the try-catch block and handles them accordingly.
// Implement a global error handler using process.on('uncaughtException')
process.on('uncaughtException', (err) => {
// Log the error message and stack trace
console.error(`Uncaught Error: ${err.message}`);
console.error(err.stack);

// Provide a meaningful response or perform some other action

// Prevent application crashes by exiting with code 1
process.exit(1);
});

When handling errors globally, consider the following best practices:

  1. Log Error Messages and Stack Traces: Log error messages and stack traces to aid in debugging and troubleshooting.
  2. Provide Meaningful Responses: Provide meaningful responses or perform some other action when an error occurs.
  3. Prevent Application Crashes: Prevent application crashes by exiting with code 1.
// Implement a global error handler using a custom function
function handleError(err) {
// Log the error message and stack trace
console.error(`Error: ${err.message}`);
console.error(err.stack);

// Provide a meaningful response or perform some other action
}

process.on('uncaughtException', (err) => {
handleError(err);
});

In addition to implementing a global error handler, consider the following best practices:

  1. Use Error Handling Middleware: Use middleware like Express.js’s built-in error handling middleware or third-party libraries like errorhandler to catch and handle errors.
  2. Implement a Custom Error Handler: Implement a custom error handler using a framework-specific function or by creating your own error handling module.
// Implement a global error handler using Express.js's built-in error handling middleware
app.use((err, req, res, next) => {
// Log the error message and stack trace
console.error(err.message);
console.error(err.stack);

// Provide a meaningful response or perform some other action

// Prevent application crashes by exiting with code 1
res.status(500).send('Internal Server Error');
});

// Implement a custom error handler using a custom function
function customErrorHandler(err) {
// Log the error message and stack trace
console.error(`Error: ${err.message}`);
console.error(err.stack);

// Provide a meaningful response or perform some other action
}

process.on('uncaughtException', (err) => {
customErrorHandler(err);
});

Best practice 5 — Providing meaningful error messages

Providing meaningful error messages is essential because:

  1. Improved Debugging: Meaningful error messages provide crucial information that helps developers diagnose issues quickly.
  2. Reduced Support Requests: By providing clear and concise error messages, developers can reduce the number of support requests received by customers or end-user.
  3. Enhanced User Experience: Meaningful error messages help users understand what went wrong and how to recover, improving their overall experience.

To create effective error messages:

  1. Be Specific: Provide specific details about the issue, such as the cause of the error or the affected functionality.
  2. Include Context: Include relevant context, such as user input or configuration settings, to help developers understand the problem.
  3. Use Clear Language: Use clear and concise language that is easy for developers to understand.
// Example of a meaningful error message with specific details
if (error instanceof TypeError) {
console.error(`TypeError: ${error.message} - Cause: Attempted to access non-existent property '${error.property}'`);
}

To structure error messages effectively:

  1. Use a Consistent Format: Use a consistent format for all error messages, including the type of error, the cause, and any relevant context.
  2. Prioritize Critical Information: Prioritize critical information, such as the cause of the error or the affected functionality, to ensure that it is easily visible.
// Example of an error message with a consistent format
if (error instanceof TypeError) {
console.error({
type: 'Error',
title: 'TypeError',
description: `Attempted to access non-existent property '${error.property}'`,
cause: 'Invalid user input',
});
}

In addition to providing meaningful error messages, consider the following best practices:

  1. Use Log Levels: Use log levels (e.g., debug, info, warn, error) to categorize and prioritize error messages.
  2. Implement Error Tracking: Implement an error tracking system to collect and analyze error data.
// Example of using log levels to categorize error messages
if (error instanceof TypeError) {
console.error('warn', `Invalid user input: Attempted to access non-existent property '${error.property}'`);
}

When handling errors, consider the following best practices:

  1. Catch and Handle Specific Types of Errors: Catch and handle specific types of errors, such as TypeError or Error, to provide more targeted error messages.
  2. Provide a Default Error Message: Provide a default error message for unexpected errors that cannot be handled by the application.
// Example of catching and handling specific types of errors
try {
// Code that might throw an error
} catch (error) {
if (error instanceof TypeError) {
console.error(`TypeError: ${error.message}`);
} else {
console.error('An unexpected error occurred.');
}
}

Thanks for reading!

--

--

Tech Tonic
Tech Tonic

Published in Tech Tonic

Articles on popular things like Node.js, Deno, Bun, etc.

Mayank C
Mayank C

No responses yet