Error Handling in Node.js: Production Best Practices

Error handling in Node.js is not just about adding a few try/catch blocks. In production applications, good error handling determines whether a small failure becomes a useful API response, a clear log entry, or a full application outage. Over time, I have found that the hardest part is not catching an error. The harder part…

Error handling in Node.js
guide.md READY

Error handling in Node.js is not just about adding a few try/catch blocks. In production applications, good error handling determines whether a small failure becomes a useful API response, a clear log entry, or a full application outage.

Over time, I have found that the hardest part is not catching an error. The harder part is deciding where to handle it, what to log, what to return to the client, and when the process should stop instead of continuing in an unsafe state.

In this guide, I’ll show a practical approach to error handling in Node.js using TypeScript and Express-style APIs, while also covering Promises, database errors, custom error classes, process-level failures, logging, streams, and graceful shutdown.

How Error Handling in Node.js Actually Works

Node.js errors can reach your application in several different ways.

Synchronous code
→ throws an exception

Promise-based async code
→ rejects a Promise

Callback-based APIs
→ often pass error as first callback argument

Streams / EventEmitters
→ may emit an "error" event

Process-level failures
→ uncaughtException / unhandledRejection

This distinction matters because one error-handling technique does not work everywhere.

For example, Node.js documents that synchronous APIs generally report failures by throwing, while Promise-based APIs reject and many stream-based APIs use an error event.

1. Handle Synchronous Errors with try/catch

For synchronous code, try/catch is straightforward.

function parseSettings(value: string) {
  try {
    return JSON.parse(value);
  } catch (error) {
    console.error(
      'Failed to parse settings',
      error
    );

    return null;
  }
}

This works because JSON.parse() throws during the same execution flow.

The mistake is assuming that the same outer try/catch can automatically catch every asynchronous error.

2. Handle Promise Errors with async/await

For modern Node.js code, I usually prefer async/await because it keeps asynchronous logic readable.

async function getUser(
  userId: number
) {
  try {
    const response = await fetch(
      `https://api.example.com/users/${userId}`
    );

    if (!response.ok) {
      throw new Error(
        `Request failed with status ${response.status}`
      );
    }

    return await response.json();
  } catch (error) {
    console.error(
      'Failed to fetch user',
      error
    );

    throw error;
  }
}

Notice that I rethrow the error after logging it.

This is important when the current function does not have enough context to decide how the application should respond.

Do Not Catch Errors Just to Hide Them

One pattern I try to avoid is catching an error and then pretending everything succeeded.

For example:

async function saveOrder() {
  try {
    await database.save();
  } catch (error) {
    console.log(error);
  }

  return {
    success: true,
  };
}

This is dangerous because the caller receives success: true even when the database operation failed.

A better approach is either to handle the failure meaningfully or allow it to propagate.

async function saveOrder() {
  try {
    await database.save();

    return {
      success: true,
    };
  } catch (error) {
    throw new Error(
      'Unable to save order',
      {
        cause: error,
      }
    );
  }
}

The cause property is useful because it lets you add application context without completely losing the original failure.

3. Create Application-Specific Error Classes

In larger APIs, returning 500 Internal Server Error for every failure makes debugging and client behavior harder.

I prefer defining a small application error class.

export class AppError extends Error {
  constructor(
    message: string,
    public readonly statusCode = 500,
    public readonly code = 'INTERNAL_ERROR',
    public readonly isOperational = true,
    options?: ErrorOptions
  ) {
    super(message, options);

    this.name =
      this.constructor.name;
  }
}

Now expected application failures can be much more explicit.

throw new AppError(
  'User not found',
  404,
  'USER_NOT_FOUND'
);

or:

throw new AppError(
  'Email already exists',
  409,
  'EMAIL_ALREADY_EXISTS'
);

4. Operational Errors vs Programming Errors

This is one of the most useful distinctions I make when designing Node.js error handling.

Operational errors

These are expected failures that can happen even when the application code is correct.

  • invalid user input
  • record not found
  • duplicate email address
  • payment declined
  • third-party API timeout
  • database temporarily unavailable
  • rate limit exceeded

These usually need a controlled response.

Programming errors

These normally indicate a bug.

  • accessing a property on undefined
  • invalid assumptions in application logic
  • incorrect function arguments
  • unexpected invariant violations
  • logic that should never be reached

Trying to silently recover from every programming error can leave the process in an unknown state.

5. Use Centralized Error Handling in Express APIs

One of the biggest improvements I make in API projects is keeping route handlers focused on application logic and moving response formatting into centralized error middleware.

A route can stay very small:

app.get(
  '/users/:id',
  async (req, res) => {
    const user =
      await userService.findById(
        Number(req.params.id)
      );

    res.json(user);
  }
);

With Express 5, rejected Promises and errors thrown by async route handlers are automatically forwarded to error-handling middleware.

Then add one centralized handler near the end of the middleware stack:

import type {
  ErrorRequestHandler,
} from 'express';

export const errorHandler:
  ErrorRequestHandler =
(
  error,
  req,
  res,
  next
) => {
  if (res.headersSent) {
    return next(error);
  }

  if (error instanceof AppError) {
    return res
      .status(error.statusCode)
      .json({
        error: {
          code: error.code,
          message: error.message,
        },
      });
  }

  console.error(error);

  return res
    .status(500)
    .json({
      error: {
        code: 'INTERNAL_ERROR',
        message:
          'Something went wrong',
      },
    });
};

Then register it after your routes:

app.use(errorHandler);

Express error middleware uses the four-argument signature (err, req, res, next), which is how Express identifies it as an error handler.

6. Do Not Return Internal Error Details to Clients

A development environment and production environment should not expose the same error information.

This is useful internally:

TypeError:
Cannot read properties of undefined

at UserService.findUser
at OrderController.create
at node:internal/...

But I would not normally send that entire stack trace to a public API client.

Prefer a controlled response:

{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "Something went wrong"
  }
}

Log the detailed error on the server. Return only what the client needs.

7. Add Request Context to Logs

A log saying only:

Database failed

usually does not tell me enough.

Production logs become much more useful when they include context.

logger.error(
  {
    err: error,
    requestId:
      req.requestId,
    userId:
      req.user?.id,
    method:
      req.method,
    path:
      req.path,
  },
  'Request failed'
);

I especially like having a request or correlation ID because it makes it easier to follow one request through several services.

At the same time, I avoid logging sensitive information such as passwords, access tokens, authorization headers, or private customer data unless there is a very specific need.

8. Handle Database Errors at the Right Layer

Database libraries often return implementation-specific errors.

I generally do not want PostgreSQL, Prisma, TypeORM, or MongoDB error details leaking directly into controllers or public responses.

Instead, translate known database failures into application errors.

async function createUser(
  input: CreateUserInput
) {
  try {
    return await repository.create(
      input
    );
  } catch (error) {
    if (
      isDuplicateEmailError(
        error
      )
    ) {
      throw new AppError(
        'Email already exists',
        409,
        'EMAIL_ALREADY_EXISTS',
        true,
        {
          cause: error,
        }
      );
    }

    throw error;
  }
}

This gives the rest of the application a stable error contract even if the database implementation changes later.

9. Add Timeouts to External API Calls

A third-party API that never responds can consume resources and leave requests waiting much longer than expected.

Modern Node.js fetch() supports AbortSignal, so one practical pattern is:

async function fetchPayment(
  paymentId: string
) {
  const controller =
    new AbortController();

  const timeout =
    setTimeout(
      () =>
        controller.abort(),
      5000
    );

  try {
    const response =
      await fetch(
        `https://api.example.com/payments/${paymentId}`,
        {
          signal:
            controller.signal,
        }
      );

    if (!response.ok) {
      throw new AppError(
        'Payment service failed',
        502,
        'PAYMENT_SERVICE_ERROR'
      );
    }

    return await response.json();
  } finally {
    clearTimeout(timeout);
  }
}

In production, I also think about retry behavior carefully.

Retries are useful for some temporary failures, but blindly retrying everything can make an outage worse.

10. Retry Only Errors That May Actually Recover

I do not retry errors such as:

  • invalid credentials
  • bad request payloads
  • permission errors
  • business validation failures

I may retry failures such as:

  • temporary network failures
  • HTTP 503 responses
  • rate limiting when the provider gives a retry strategy
  • temporary database connection problems

For background jobs, I normally prefer retry policies with exponential backoff rather than retrying immediately in a tight loop.

11. Be Careful with Promise.all()

Promise.all() rejects when one of the included Promises rejects.

const [
  user,
  orders,
  notifications,
] = await Promise.all([
  getUser(),
  getOrders(),
  getNotifications(),
]);

That is exactly what I want when all three results are required.

But sometimes partial success is acceptable.

In that situation, Promise.allSettled() may be more appropriate:

const results =
  await Promise.allSettled([
    getRecommendations(),
    getNotifications(),
    getActivityFeed(),
  ]);

for (const result of results) {
  if (
    result.status ===
    'rejected'
  ) {
    console.error(
      result.reason
    );
  }
}

The important thing is deciding whether one failure should fail the entire operation.

12. Streams Need Their Own Error Handling

This is an area that is easy to overlook if most of your Node.js code uses Promises.

Streams and other EventEmitter-style APIs may emit an error event.

import {
  createReadStream,
} from 'node:fs';

const stream =
  createReadStream(
    './large-file.csv'
  );

stream.on(
  'error',
  (error) => {
    console.error(
      'File stream failed',
      error
    );
  }
);

Node.js documents that an EventEmitter emitting error without an appropriate listener can cause the process to terminate, so stream errors should not be ignored.

13. Understand unhandledRejection

An unhandled Promise rejection means a Promise rejected without an error handler being attached in time.

async function run() {
  throw new Error(
    'Something failed'
  );
}

run();

If nothing handles that rejected Promise, it becomes a process-level problem.

Node.js exposes the unhandledRejection event for observing these failures.

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

I treat this as a final safety net and observability mechanism—not as a replacement for handling Promise failures where they actually occur.

14. Understand uncaughtException

An uncaught exception is more serious.

process.on(
  'uncaughtException',
  (error) => {
    console.error(
      'Uncaught exception',
      error
    );

    process.exit(1);
  }
);

I do not use this event to keep the application running indefinitely.

Node.js warns that attempting to continue normal operation after an uncaught exception is unsafe because the application may be in an undefined state.

Instead, I use it to perform minimal synchronous cleanup or logging and let an external process manager, container platform, or orchestration system restart the application.

15. Gracefully Shut Down the Application

A production application should also handle normal shutdown signals.

For example:

const server =
  app.listen(3000);

async function shutdown(
  signal: string
) {
  console.log(
    `${signal} received`
  );

  server.close(
    async () => {
      try {
        await database.close();

        console.log(
          'Shutdown complete'
        );

        process.exit(0);
      } catch (error) {
        console.error(
          'Shutdown failed',
          error
        );

        process.exit(1);
      }
    }
  );
}

process.on(
  'SIGTERM',
  () =>
    shutdown('SIGTERM')
);

process.on(
  'SIGINT',
  () =>
    shutdown('SIGINT')
);

The exact implementation depends on your infrastructure, but the goal is usually to stop accepting new work, finish or terminate existing work safely, close resources, and then exit.

16. Return Consistent API Errors

I like API errors to follow one predictable structure.

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "User not found",
    "requestId": "req_82fd3"
  }
}

This makes frontend handling much easier than returning a different response format from every endpoint.

For example, the frontend can make decisions based on a stable machine-readable code:

if (
  error.code ===
  'SESSION_EXPIRED'
) {
  redirectToLogin();
}

Human-readable messages can change. Stable error codes are much safer for application logic.

17. Map HTTP Status Codes Intentionally

I commonly use these mappings:

Status Typical Meaning
400 Invalid request
401 Authentication required or invalid
403 Authenticated but not allowed
404 Resource not found
409 Conflict such as duplicate data
422 Request understood but validation failed
429 Rate limit exceeded
500 Unexpected server failure
502 Upstream dependency failed
503 Service temporarily unavailable

The exact choice depends on the API contract, but consistency matters more than trying to invent a unique status code for every situation.

18. Validate Before Business Logic Runs

Many avoidable runtime errors start with invalid input.

I prefer validating at the application boundary.

import { z } from 'zod';

const CreateUserSchema =
  z.object({
    name:
      z.string().min(2),

    email:
      z.string().email(),
  });

const result =
  CreateUserSchema.safeParse(
    req.body
  );

if (!result.success) {
  throw new AppError(
    'Invalid request',
    400,
    'VALIDATION_ERROR'
  );
}

This means deeper application layers can work with data that already matches an expected shape.

19. Avoid Catching Every Error at Every Layer

Another mistake I see is repetitive handling like this:

Controller
try/catch
    ↓
Service
try/catch
    ↓
Repository
try/catch
    ↓
Database

This often creates duplicated logging and errors that lose their original stack or context.

I prefer:

Repository
→ throws technical error

Service
→ translates expected domain errors

Controller
→ mostly lets errors propagate

Global handler
→ logs + formats HTTP response

Catch an error where you can actually add value.

20. My Production Error-Handling Flow

For a typical Node.js API, my preferred flow looks roughly like this:

Incoming Request
      ↓
Validation
      ↓
Controller
      ↓
Service
      ↓
Repository / External API
      ↓
Expected failure?
      ↓
Convert to AppError
      ↓
Global Error Handler
      ↓
Structured Log
      ↓
Safe API Response

Unexpected programming errors still reach the global handler, but I treat them differently from expected business failures.

What I Log in Production

For important failures, I usually want enough context to answer:

  • What failed?
  • Which request caused it?
  • Which user or account was involved?
  • Which service or operation failed?
  • What was the original error?
  • When did it happen?

A structured log might contain:

{
  "level": "error",
  "message": "Order creation failed",
  "requestId": "req_123",
  "userId": 42,
  "operation": "createOrder",
  "errorCode": "DATABASE_TIMEOUT"
}

The production log may also contain the stack trace internally, while the public API receives only a safe message.

Common Node.js Error-Handling Mistakes

1. Swallowing errors

try {
  await runTask();
} catch {
  // nothing
}

If the failure genuinely does not matter, document why. Otherwise this makes production debugging extremely difficult.

2. Returning every error as HTTP 500

Invalid input and missing resources are not internal server failures.

3. Sending stack traces to users

Stack traces belong in observability tools and internal logs, not public production responses.

4. Logging the same error five times

If every layer catches, logs, and rethrows the same error, one failure can create several identical log entries.

5. Ignoring Promise rejections

Every Promise that can fail needs a meaningful handling path.

6. Continuing after a fatal programming error

An uncaught exception may mean the application’s state is no longer trustworthy.

7. Retrying every failure

A permanent validation error will not become successful because you retry it five times.

Error Handling Checklist for Node.js APIs

  • Validate input at application boundaries.
  • Use meaningful application error types.
  • Keep HTTP error formatting centralized.
  • Return stable machine-readable error codes.
  • Log detailed errors on the server.
  • Do not expose secrets or stack traces publicly.
  • Add request IDs for tracing.
  • Set timeouts for external services.
  • Retry only recoverable failures.
  • Handle stream and EventEmitter errors.
  • Monitor unhandled Promise rejections.
  • Treat uncaught exceptions as potentially fatal.
  • Implement graceful shutdown.
  • Use a process manager or orchestration platform to restart failed processes.

Final Thoughts

Good error handling in Node.js is less about writing more try/catch blocks and more about designing a predictable failure path.

The approach I prefer is simple:

Validate early
→ throw meaningful errors
→ let them propagate
→ handle them centrally
→ log useful context
→ return safe responses
→ crash safely when state is unreliable

That gives developers useful debugging information without exposing internal implementation details to users.

It also makes the application easier to operate because errors are handled consistently instead of differently in every route and service.

If you are building a larger Node.js backend, you may also want to read my guide on building a production-ready REST API with NestJS and my article on Node.js libraries I use in production.

Share this guideLinkedInPost

ARTICLE TOOLKIT

Save or share this guide

Keep the reference nearby or send it to a teammate solving the same problem.

Share this guideLinkedInPost

QUALITY NOTE

Written from practical development experience and reviewed for clarity. Found an outdated step?

Report a correction →

Jaydip Barad

WRITTEN BY

Jaydip Barad

Senior full-stack developer sharing production-tested lessons from 14+ years of building backend systems, WordPress platforms and modern JavaScript applications.

Node.jsTypeScriptWordPressArchitecture
Previous guide
Next guide

THE PRACTICAL DEVELOPER LETTER

Get useful engineering lessons without the noise.

New tutorials, architecture notes and tools worth knowing—delivered occasionally.




    Occasional practical tutorials. Unsubscribe any time.