Modern examples of handling errors in Node.js applications

If you build anything non-trivial in Node, you’re going to break things. The difference between a throwaway script and a production-ready service is how you handle those failures. In this guide, we’ll walk through modern, practical examples of examples of handling errors in Node.js applications, from async/await mistakes to API timeouts and validation failures. Instead of abstract theory, we’ll look at real examples that mirror what happens in everyday Node.js work: Express APIs, database calls, background jobs, and third‑party integrations. Along the way, you’ll see how to structure error classes, when to use try/catch, how to avoid unhandled promise rejections, and how to log errors in a way your future self (or your SRE team) will actually appreciate. If you’re searching for realistic examples of error handling patterns you can paste into your codebase and adapt, you’re in the right place. Let’s walk through the best examples that developers are actually using in 2024 and 2025.
Written by
Jamie
Published

Real-world examples of handling errors in Node.js applications

When people ask for examples of handling errors in Node.js applications, they usually don’t want abstract patterns. They want to see what it looks like in an Express route, a database call, or a worker process that keeps crashing at 3 a.m.

Let’s start with a simple but very common situation: an API endpoint that calls a database and might fail in several ways.

// src/errors/HttpError.js
class HttpError extends Error {
  constructor(statusCode, message, details = {}) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;
    Error.captureStackTrace?.(this, this.constructor);
  }
}

module.exports = HttpError;
// src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  console.error(err); // In production, send this to a logger instead

  const statusCode = err.statusCode || 500;
  const body = {
    error: err.message || 'Internal Server Error',
  };

  if (process.env.NODE_ENV !== 'production' && err.details) {
    body.details = err.details;
  }

  res.status(statusCode).json(body);
};

module.exports = errorHandler;
// src/routes/users.js
const express = require('express');
const HttpError = require('../errors/HttpError');

const router = express.Router();

router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;

    if (!/^[0-9a-fA-F]{24}$/.test(id)) {
      throw new HttpError(400, 'Invalid user ID format');
    }

    const user = await req.db.collection('users').findOne({ _id: id });

    if (!user) {
      throw new HttpError(404, 'User not found');
    }

    res.json(user);
  } catch (err) {
    next(err);
  }
});

module.exports = router;

This is one of the best examples of a clean pattern for Express: throw typed errors in your routes and funnel everything into a single error-handling middleware.


Async/await gone wrong: examples of avoiding unhandled promise rejections

A lot of modern Node.js code uses async/await. That’s great, until an unhandled rejection kills your process. One example of good practice is to have a global safety net while still handling errors locally.

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // In production, log and then decide whether to exit
  // For many services, you should exit so Kubernetes/systemd can restart
  process.exitCode = 1;
});

process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  // Flush logs, close connections, then exit
  process.exit(1);
});

In 2024–2025, the Node.js docs strongly encourage treating unhandled rejections as serious bugs, not “warnings you can ignore.” You can see this guidance in the official Node.js documentation (nodejs.org).

Real examples include background workers that fire off async tasks without awaiting them. If you’re not careful, those rejections get lost. A safer pattern:

async function runJob(job) {
  try {
    await job.execute();
  } catch (err) {
    console.error('Job failed', { jobId: job.id, err });
    // Decide whether to retry, dead-letter, or alert
  }
}

queue.on('job', (job) => {
  // Avoid fire-and-forget without error handling
  runJob(job).catch((err) => {
    console.error('Unexpected job error', err);
  });
});

This gives you concrete examples of handling errors in Node.js applications where background processing is involved.


Validation and user input: examples include schema-based error handling

Another set of examples of handling errors in Node.js applications comes from input validation. In 2024, using a schema validator (like zod or joi) is pretty standard, and it makes error handling more predictable.

const { z } = require('zod');
const HttpError = require('../errors/HttpError');

const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().int().min(13).optional(),
});

function validateBody(schema) {
  return (req, res, next) => {
    try {
      req.validatedBody = schema.parse(req.body);
      next();
    } catch (err) {
      if (err instanceof z.ZodError) {
        return next(new HttpError(400, 'Invalid request body', {
          issues: err.issues,
        }));
      }
      next(err);
    }
  };
}

// Usage in a route
router.post('/', validateBody(createUserSchema), async (req, res, next) => {
  try {
    const user = await createUser(req.validatedBody);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

Here, examples include:

  • Turning validation errors into consistent HTTP 400 responses.
  • Attaching structured details so the client can render helpful messages.
  • Keeping the route handler focused on business logic instead of parsing.

These are practical examples of examples of handling errors in Node.js applications that deal with user input at scale.


Database and third‑party APIs: best examples of retry, timeout, and fallback

Real production failures often come from things outside your process: databases, payment gateways, email providers, or internal microservices. The best examples of error handling here combine timeouts, retries, and circuit breakers.

const axios = require('axios');
const HttpError = require('../errors/HttpError');

async function chargeCustomer({ customerId, amount }) {
  try {
    const response = await axios.post(
      'https://payments.example.com/charge',
      { customerId, amount },
      { timeout: 5000 } // 5s timeout
    );

    return response.data;
  } catch (err) {
    if (err.code === 'ECONNABORTED') {
      throw new HttpError(504, 'Payment service timeout');
    }

    if (err.response) {
      // Upstream returned an error status
      if (err.response.status === 402) {
        throw new HttpError(402, 'Payment required');
      }
      throw new HttpError(502, 'Payment service error');
    }

    throw new HttpError(502, 'Unable to reach payment service');
  }
}

In this example of error handling, you:

  • Translate low-level network errors into meaningful HTTP errors.
  • Distinguish between timeouts, upstream 4xx/5xx, and DNS/connection issues.
  • Avoid leaking internal error shapes to the client.

If you want to go further, libraries like opossum implement circuit breakers, which can stop hammering a failing dependency and return a fast, predictable error instead.


Logging and monitoring: examples of making errors observable

Error handling without observability is just guesswork. In 2024–2025, teams lean on structured logging and tracing so they can see patterns in failures.

Here’s a simple but realistic pattern using a logger like pino:

const pino = require('pino');
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });

const errorHandler = (err, req, res, next) => {
  logger.error({
    err,
    path: req.path,
    method: req.method,
    requestId: req.headers['x-request-id'],
    userId: req.user?.id,
  }, 'Request failed');

  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: err.message || 'Internal Server Error',
  });
};

This is one of the best examples of handling errors in Node.js applications where you care about:

  • Correlating errors to specific requests.
  • Including context like user IDs without dumping sensitive data.
  • Feeding structured logs into tools like Elasticsearch, Loki, or a hosted platform.

For guidance on logging and monitoring practices, engineering teams often look at public SRE materials from organizations like Google and the US National Institute of Standards and Technology (nist.gov) for general reliability principles, even though they’re not Node-specific.


Security-focused examples of handling errors in Node.js applications

Security bugs often start as “harmless” error messages that leak too much information. Here’s an example of tightening that up.

const errorHandler = (err, req, res, next) => {
  const isProd = process.env.NODE_ENV === 'production';

  // Never send stack traces to clients in production
  const response = {
    error: isProd ? 'Something went wrong' : err.message,
  };

  if (!isProd && err.stack) {
    response.stack = err.stack;
  }

  const statusCode = err.statusCode || 500;
  res.status(statusCode).json(response);
};

Real examples include:

  • Hiding SQL queries, file paths, or stack traces from API responses.
  • Avoiding JSON.stringify(err) directly into the response.
  • Normalizing messages like “Invalid credentials” instead of “User not found” vs “Wrong password,” which can leak information.

For secure coding principles, OWASP’s guidance at owasp.org is widely referenced by Node.js teams.


Graceful shutdown: examples include cleaning up on fatal errors

Sometimes the right error handling strategy is: log, respond if you can, and then shut the service down in a controlled way.

function setupGracefulShutdown(server, { logger }) {
  const shutdown = (signal) => {
    logger.warn({ signal }, 'Received shutdown signal');

    server.close((err) => {
      if (err) {
        logger.error({ err }, 'Error during server close');
      }
      // Close DB connections, flush logs, etc.
      process.exit(err ? 1 : 0);
    });

    // Fallback if close hangs
    setTimeout(() => {
      logger.error('Forced shutdown after timeout');
      process.exit(1);
    }, 10000).unref();
  };

  process.on('SIGINT', shutdown);
  process.on('SIGTERM', shutdown);
}

This is a practical example of examples of handling errors in Node.js applications that run inside containers or orchestrated environments. Rather than crashing instantly, you:

  • Stop accepting new connections.
  • Let in-flight requests finish.
  • Then terminate.

The US government’s guidance on high-availability systems (for example, materials published through nist.gov) often highlight this kind of controlled shutdown as part of reliable service design.


Testing: real examples of asserting on error behavior

Error handling that isn’t tested tends to rot. Here’s a Jest test that asserts your API returns the right status and message when something fails.

const request = require('supertest');
const app = require('../src/app');

describe('GET /users/:id', () => {
  it('returns 400 for invalid ID format', async () => {
    const res = await request(app).get('/users/not-an-id');

    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/invalid user id/i);
  });

  it('returns 404 for missing user', async () => {
    const res = await request(app).get('/users/507f1f77bcf86cd799439011');

    expect(res.status).toBe(404);
    expect(res.body.error).toMatch(/not found/i);
  });
});

These are concrete examples of handling errors in Node.js applications where you:

  • Lock in the contract between the server and the client.
  • Avoid regressions when someone refactors your error classes.
  • Treat error behavior as part of the public API.

For general testing strategies (not Node-specific), university resources such as software engineering courses at harvard.edu often discuss how to test error paths and edge cases.


FAQ: short examples-focused answers

Q: What are some common examples of handling errors in Node.js applications?
Common examples include Express error middleware that centralizes responses, async/await try/catch blocks around database calls, schema-based validation that throws structured 400 errors, global handlers for unhandledRejection and uncaughtException, and graceful shutdown logic that closes servers and database connections on fatal errors.

Q: Can you give an example of converting low-level errors into HTTP responses?
Yes. A typical pattern is to catch database or HTTP client errors, inspect their type or status code, and then throw a custom HttpError with a specific status (like 400, 404, 502, or 504) that your Express error handler converts into a JSON response.

Q: How many real examples should a Node.js codebase have for error handling patterns?
You don’t need dozens of patterns. A small set of well-implemented examples of error handling—central error middleware, validation helpers, typed errors for business rules, and a shutdown hook—is usually enough. The key is to reuse these patterns consistently instead of inventing a new style in every file.

Q: What is an example of bad error handling in Node.js?
A classic bad example is catching an error and doing nothing with it, or just logging it with console.log and continuing as if nothing happened. Another is sending raw stack traces to clients, which can leak internal details and confuse users.

Q: How do modern trends in 2024–2025 affect Node.js error handling?
Teams are moving toward structured logging, distributed tracing, and typed error classes, especially in TypeScript codebases. There’s also more focus on resilience patterns—timeouts, retries, circuit breakers—because Node.js apps are often part of larger microservice architectures where partial failures are normal.

Explore More Node.js Code Snippets

Discover more examples and insights in this category.

View All Node.js Code Snippets