Error Handling In Node.Js: A Detailed Guide

Regardless of the type of app you are building, errors are inevitable, and thus, error handling is a core part of writing reliable software. In web apps built with Node.js, this becomes even more important. HTTP servers are long-running processes, and one bad request handler, one failed database call, one timeout, or one mistyped input can bring the entire service down if not caught properly.

Unlike strongly typed languages, JavaScript doesn’t catch many of these issues at compile time. Type checking is minimal unless you introduce TypeScript, so you're more likely to hit runtime errors if you're not careful.

This guide walks you through everything you need to know about error handling in Node.js: the different types of errors, multiple ways to handle them, how to create custom errors and exceptions, and some best practices for writing clean, fault-tolerant code.

Error Handling Techniques in Node.js

Let’s start by looking at the main error handling techniques in Node.js.

1. try...catch

What it is:

This is the most direct way to handle errors in synchronous code or code using async/await.

When to use it:

Use try...catch when you’re dealing with potentially risky code that can throw, like JSON.parse, or any function that may throw a synchronous error. It also works with await, so it's useful in async functions too.

Example:

async function loadUser() {
try {
const user = await getUserFromDb();
console.log(user.name);
} catch (err) {
console.error('Failed to load user:', err.message);
}
}

Additional tips:

It won’t catch errors in async callbacks, promises (unless used with await), or event handlers. Also, avoid wrapping large blocks of code; keep it focused so you can be specific about what failed.

2. Callback-Based Error Handling

What it is:

This is the old-school Node.js way, where callbacks take an error as the first argument.

When to use it:

Still common when working with older libraries, especially the Node.js standard library (like fs, http, etc.).

Example:

fs.readFile('file.txt', (err, data) => {
if (err) {
console.error('Error reading file:', err.message);
return;
}
console.log(data.toString());
});

Additional tips:

If you’re writing new code, prefer promises or async/await for cleaner flow. Managing nested callbacks can get messy fast (callback hell).

3. .catch() on Promises

What it is:

The .catch() method lets you handle rejected promises.

When to use it:

Good when chaining multiple promises or when you don't want to use async/await. Use it to handle rejections in a clear, isolated way.

Example:

fetchUser()
.then(data => processUser(data))
.catch(err => console.error('Error fetching user:', err));

Additional tips:

Don’t mix .then/.catch with async/await in the same block. It gets confusing and can lead to unhandled rejections if you forget a .catch().

4. Event-Based Error Handling

What it is:

Some Node.js objects like streams and child_process use an event emitter model. You have to listen for an error event.

When to use it:

Whenever you're working with streams, sockets, servers, or anything that inherits from EventEmitter.

Example:

const server = http.createServer();

server.on('error', (err) => {
console.error('Server error:', err);
});

Additional tips:

If you forget to attach an error listener, your app can crash on unhandled errors. Also, event emitters don’t bubble errors upward, so make sure you attach the listener early.

5. Global Error Handlers (process events)

What it is:

These are last-resort mechanisms to catch errors that were missed elsewhere. Common ones include uncaughtException and unhandledRejection.

When to use it:

Only as a fallback to log the error, clean up resources, and shut down the app gracefully.

Example:

process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
process.exit(1);
});

process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection:', reason);
});

Additional tips:

Don’t rely on this for normal error handling. Once you're in these handlers, your app is in an unpredictable state. Usually, it's best to log and exit.

6. Middleware Error Handling (for Express and similar frameworks)

What it is:

In web frameworks like Express, you can define centralized error-handling middleware using four parameters: err, req, res, next.

When to use it:

Use this to catch errors from routes, handle HTTP error responses consistently, and avoid repetitive try...catch in each route.

Example:

app.use((err, req, res, next) => {
console.error('Error:', err.message);
res.status(500).json({ message: 'Something went wrong' });
});

Additional tips:

Don’t overuse this for catching errors that should be handled closer to where they happen (e.g., validating input or catching expected rejections).

Types of Errors in Node.js

Next, let’s look at the main categories of errors in Node.js applications:

Synchronous Errors

These are errors that happen during the execution of regular, blocking code. They can be caught using try...catch.

Example:

try {
const result = JSON.parse('this is not valid JSON');
} catch (err) {
console.error('Failed to parse JSON:', err.message);
}

If you don’t wrap this in try...catch, it will throw and possibly crash your app.

Asynchronous Errors

These happen in non-blocking code, like callbacks or async functions. You need different handling patterns depending on the async model.

Callback Example:

fs.readFile('/some/file.txt', (err, data) => {
if (err) {
console.error('Failed to read file:', err.message);
return;
}
console.log(data.toString());
});
Promise Example:
fetchData()
.then(data => console.log(data))
.catch(err => console.error('Error during fetch:', err));
Async/Await Example:
try {
const data = await fetchData();
console.log(data);
} catch (err) {
console.error('Fetch failed:', err);
}

Runtime Errors

These are bugs in your logic that show up while the app is running. Examples can be: accessing a property of undefined or calling a function that doesn't exist.

Example:

const user = null;
console.log(user.name); // TypeError: Cannot read property 'name' of null

These are usually the result of bad assumptions or missing null checks.

Errors from Lack of Static Typing

JavaScript won’t warn you if you pass a number instead of a string, or if a function expects an object but gets undefined. This kind of error won’t show up until runtime.

Example:

function greet(user) {
console.log(`Hello, ${user.name}`);
}

greet(null); // TypeError at runtime

greet(null); // TypeError at runtime

These issues are why many teams adopt TypeScript or use runtime type validators like Joi or zod.

Operational Errors vs. Programmer Errors

This is a useful distinction introduced by Node.js core contributors:

  • Operational errors are expected issues: failed DB connections, file not found, network timeouts, query failures.
  • Programmer errors are bugs: undefined variables, bad logic, invalid types, incorrect assumptions.

Operational Error Example:

fs.readFile('/nonexistent.txt', (err, data) => {
if (err) {
console.error('Expected file error:', err.code); // ENOENT
}
});

Programmer Error Example:

const res = someUndefinedFunction(); // ReferenceError

You should handle operational errors gracefully. Programmer errors often mean something is broken and needs to be fixed in the code itself.

System and Environment Errors

These are thrown by the runtime or OS. Examples can be: running out of memory, permission issues, signals like SIGTERM, or CPU exhaustion.

Example:

process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
process.exit(1); // Always exit after this
});

These errors are often outside your app’s control, and while you can detect them, you usually can’t recover from them cleanly.

Custom Errors

Custom errors are error objects that you can define yourself by extending the built-in Error class. They let you create meaningful error messages and attach extra context that’s helpful for logging and debugging.

So, instead of throwing a generic error like this:

throw new Error('Something went wrong');

You can create a named error that makes it easier to track the issue:

throw new ValidationError('User input is invalid');

When Do You Need Custom Errors?

Custom errors aren’t required for every app, but they become useful when your project starts to grow. Here are some common scenarios where they help:

  • You want clearer and more structured error logs
  • You need to differentiate between types of errors (e.g., AuthError vs. DbError)
  • You’re building a public API and need to return different HTTP status codes for different problems
  • You want to avoid leaking internal error details to clients
  • You have complex business rules and need to show specific failure reasons
  • You want to handle operational vs. programmer errors differently
  • You're working in a team and want to make error intent obvious to others
  • You’re using error tracking tools and want cleaner grouping and classification
  • You want to attach metadata to errors (like error codes, user IDs, timestamps)
  • You’re building reusable libraries or modules that other parts of the system consume
  • You want to bubble up only actionable or known errors to your global error handlers

Example: Defining a Custom Error

Here’s a basic custom error class:

class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
this.statusCode = 400;
}
}

You can then use it like this:

function validateUserInput(input) {
if (!input.email) {
throw new ValidationError('Email is required');
}
}

More Example Scenarios

User Authentication

class AuthError extends Error {
constructor(message = 'Authentication failed') {
super(message);
this.name = 'AuthError';
this.statusCode = 401;
}
}

function authenticate(token) {
if (!isValid(token)) {
throw new AuthError();
}
}

Database Operation

class DbError extends Error {
constructor(message = 'Database error') {
super(message);
this.name = 'DbError';
this.statusCode = 500;
}
}

Permission Denied

class PermissionError extends Error {
constructor(message = 'You do not have permission') {
super(message);
this.name = 'PermissionError';
this.statusCode = 403;
}
}

External Service Failure

class ExternalServiceError extends Error {
constructor(serviceName, message) {
super(`${serviceName} failed: ${message}`);
this.name = 'ExternalServiceError';
this.statusCode = 502;
}
}

Rate Limiting

class RateLimitError extends Error {
constructor(retryAfterSeconds) {
super('Rate limit exceeded');
this.name = 'RateLimitError';
this.statusCode = 429;
this.retryAfter = retryAfterSeconds;
}
}

Proper Error Logging Techniques

Error logs are often your only clue when something goes wrong in production. Whether it's a crash or a strange edge case, logs help you understand what broke, when, where, and why. Without good logging, you're left guessing.

But just logging err.message isn't enough. To make logs useful, they need to include the right context and be consistent. Below are some key techniques to help you write useful and clean error logs.

  • Instead of logging just the error message, always include the full stack trace. This gives you the exact line and file where the error started, which makes troubleshooting easier.
  • Add relevant context around the error, such as request paths or input data. This helps you understand what triggered the error and under what conditions it happened. For example:
console.error(`Error in /api/users/:id - userId=${userId}`, err.stack);
  • Use structured logging formats like JSON if you're working with tools like Winston or Pino. This allows logs to be parsed, searched, filtered, and analyzed by external tools like Site24x7 AppLogs. For example:
logger.error({
message: 'Failed to fetch order',
orderId,
userId,
error: err.message,
stack: err.stack,
});
  • Avoid logging sensitive information such as passwords, access tokens, encryption keys, or user PII. Even a single mistake here can lead to compliance issues or security breaches.
  • If you're using custom error classes, include flags or fields like isOperational: true to help distinguish between expected runtime errors and actual bugs.
  • Make sure logs include timestamps, especially if you're not using a logger that adds them automatically. Knowing when something failed is just as important as knowing what failed.
  • Don’t log the same error multiple times across layers. If you already log an error in a global handler or middleware, avoid duplicating it inside lower-level functions. This keeps both your code and log files clean and readable.
  • Assign unique error codes or identifiers to make your logs easier to track, especially in large systems where the same type of error might appear in different places.
logger.error({
code: 'AUTH_TOKEN_EXPIRED',
message: 'User token expired during session validation',
userId,
stack: err.stack,
});

Error Handling Best Practices

Finally, here’s a list of best practices that can help you write cleaner, more reliable Node.js applications by improving how you handle errors.

  • Always validate user input at the boundary of your application. Use libraries like Joi or zod to catch invalid data early and throw predictable errors instead of letting things break deep in your code.
  • Keep your try...catch blocks focused and narrow in scope. Wrapping too much logic in a single block can hide the real source of the error and make the code hard to debug.
  • Avoid swallowing errors silently. If you catch an error and don’t rethrow it or handle it properly, it can leave your app in a broken or inconsistent state.
  • Use custom error classes to represent different types of expected failures in your app. This makes your error handling cleaner.
  • Don’t rely on global handlers like process.on('uncaughtException') or process.on('unhandledRejection') as your main error handling strategy. Use them only as a last resort to clean up and exit safely.
  • Always return consistent error responses in APIs. This helps clients understand what went wrong and react properly, whether it’s a validation issue or a system failure.
  • Avoid throwing strings or plain objects as errors. Always use Error instances or classes that extend Error to preserve the stack trace and make the error easier to track.
  • Be cautious with async error handling in event emitters or callbacks. Missing an error event listener can crash your app unexpectedly.
  • Use finally blocks where necessary to clean up resources like database connections or file handles.
  • If you're using third-party libraries, check how they handle errors. Some throw, some use callbacks, and others return rejected promises. Handle them accordingly.
  • Fail fast in places where recovery isn’t possible. For example, if a required config is missing at startup, exit immediately rather than trying to continue with partial settings.
  • Use default fallbacks or guards where appropriate. If a non-critical function fails, you may be able to fall back to a cached value or a default response instead of breaking the whole flow.
  • Handle common asynchronous failure points, like timeouts or failed dependencies, with retries or circuit breakers to prevent cascading failures.
  • Make all asynchronous functions return a promise, even if they’re small utility functions. This keeps your error handling consistent and allows try...catch to work reliably when using async/await.
  • Always check for null or undefined before accessing nested properties. Use optional chaining or defensive checks to avoid runtime crashes due to bad assumptions.
  • Write unit tests that include both success and failure scenarios. It’s much cheaper to catch error-handling bugs during testing than finding them in production.
  • Don’t expose internal stack traces or implementation details to end users in API responses. Sanitize error output before sending it to clients.
  • Use Promise.allSettled() when you want to run multiple promises in parallel but don’t want one failure to stop the rest. This gives you better control over partial failures.
  • Prefer early returns in functions when input is invalid or a condition fails. This keeps your code cleaner and reduces the risk of errors in deeper logic that shouldn’t run.
  • Consider using TypeScript or gradually moving to it to catch type-related issues before they reach runtime. Static typing helps prevent several common bugs that are otherwise easy to miss in plain JavaScript.
  • Use a dedicated monitoring tool like Site24x7 to track error rates, failed transactions, timeouts, and overall application health in real time.

Building robust error handling for production Node.js applications

Developing production-grade Node.js applications requires moving beyond basic error handling to implement comprehensive error management strategies. The techniques and best practices covered in this guide form the foundation of resilient applications that remain stable under real-world conditions.

However, code-level error handling represents only half the equation. To truly understand and prevent errors before they impact users, you need end-to-end visibility into your Node.js application's behavior. This is where observability and application performance monitoring become critical for modern development teams.

Why error monitoring matters in production

While implementing solid error handling practices protects your application from crashes, monitoring error rates and patterns in production reveals insights you cannot get from code review alone. Production environments surface edge cases, timing issues, resource constraints, and integration failures that don't appear in development or testing.

    Key observability metrics for Node.js error management include:

  • Error rate trends: Track how error frequencies change over time to identify emerging issues before they escalate
  • Error type distribution: Understand which types of errors (timeout, database, external service, validation) occur most frequently
  • Error impact analysis: Correlate errors with user-facing issues, failed transactions, and performance degradation
  • Stack trace clustering: Automatically group similar errors to identify root causes and prioritize fixes
  • Error context: Capture user session data, request parameters, and system state at error time to accelerate debugging
  • Dependency health: Monitor error rates from third-party services and APIs to detect integration failures early

Extending error handling with application performance monitoring

Site24x7's application performance monitoring (APM) platform extends your error handling strategy with production observability that catches errors and performance issues your code-level practices might miss.

For Node.js applications, Site24x7 APM provides:

  • Real-time error detection and alerting: Automatically detect errors as they occur and get alerted before customer impact, with intelligent anomaly detection that flags unusual error patterns
  • Distributed error tracking: Track errors across microservices and external dependencies to understand how failures propagate through your system architecture
  • Transaction error analysis: See which business-critical transactions are failing and their impact on user experience
  • Error-related performance insights: Identify performance bottlenecks associated with error handling code itself
  • Log integration: Correlate structured error logs with application metrics, traces, and user sessions for comprehensive debugging context
  • Error rate dashboards: Create custom dashboards for different services, teams, or business functions to track error handling health
  • Historical error analysis: Search and replay error scenarios to understand patterns and prevent recurrence

Site24x7 Node.js monitoring

The complete error management lifecycle

Implementing proper error handling in your Node.js code is essential—and monitoring those errors in production is equally important. Together, they create a complete error management lifecycle:

For Node.js applications, Site24x7 APM provides:

  • Prevention: Implement validation, TypeScript, and defensive coding patterns
  • Handling: Use appropriate error handling techniques (try...catch, promises, event handlers)
  • Logging: Capture structured error data with context and metadata
  • Monitoring: Track errors in production with real-time alerting and analysis via APM
  • Remediation: Analyze error patterns to fix root causes and improve resilience

Start monitoring your Node.js application errors with Site24x7 APM today. The combination of solid code practices and comprehensive application monitoring provides the reliability and visibility your users expect.

Was this article helpful?

Related Articles