Mastering Error Handling in JavaScript

Mastering Error Handling in JavaScript


Poor error handling is one of the leading causes of unreliable behavior in JavaScript applications. Even in well-tested systems, silent failures or unclear error messages can make debugging and maintenance extremely painful.

Common pitfalls include:

  • unhandledRejection: Unhanded promise rejections that go unnoticed until they cause critical crashes.

  • uncaughtException: Exceptions that bubble all the way up and crash the application.

  • Deeply nested try/catch blocks: These often obscure stack traces and make debugging harder.

  • Generic error messages: Logs like "Something went wrong" or "Unknown error" provide no actionable context.

  • Lost context: Catching and suppressing errors without preserving their origin makes root cause analysis nearly impossible.


Examples

function getUserData(id: string): any {
  try {
    throw new Error("Database connection failed");
  } catch (e) {
    console.error("An error occurred"); // No context!
    return null;
  }
}

What’s wrong here:

  1. The error is caught but not properly logged or re-thrown.

  2. Valuable debugging information (like the stack trace) is lost.

  3. There’s an attempt to control the code flow by suppressing the error and returning null, which can lead to silent failures. This pattern also introduces unnecessary overhead, including:

    • Memory pressure from frequent heap allocations of Error objects.

    • Stack trace generation, which is computationally expensive.

    • Disrupted control flow, making the code harder to optimize and maintain.

  4. The function allows execution to continue in an invalid or inconsistent state.

And things can get even worse when nested try-catch blocks are involved:

function getUserFromDB(id) {
  try {
    throw new Error("Database timeout"); // Original error
  } catch (e) {
    console.error("An error occurred");
    return null;
  }
}

function fetchUserProfile(id) {
  try {
    const response = getUserFromDB(id);

    if (!response) {
      throw new Error("Failed to fetch user profile");
    }
  } catch (e) {
    console.log(e.stack); // the stack will point to second error trace, original error is lost
  }
}

Even though these examples may look simple, this kind of flawed error handling can quickly escalate into a hard-to-maintain application. If proper error context isn’t preserved and surfaced early, diagnosing issues later becomes significantly more complex and costly.

Now that we've outlined the core problems and common pitfalls in error handling, let’s explore some practical strategies to improve how errors are managed in a JavaScript API.

Avoid Using Exceptions to Control Application Flow

Don't use throw to manage expected situations like input validation or missing data.

// ❌ Bad practice
if (!input.name) {
  throw new Error("Name is required");
}

// ✅ Better approach
if (!input.name) {
  validation_error.append("name is required");
}
//... other validations
return validation_error;

// return 400 if array it's not empty, can early return at first wrong param, will depend on the API requirements

Errors should represent unexpected conditions, not normal application logic failures. Input validation, business rules, and authentication errors should be handled gracefully with appropriate responses, not exceptions.

Let Errors Bubble Up to the Outer Layer

Avoid catching exceptions at low-level modules (like repositories or services). Let the error bubble up to the outermost layer, typically the controller in an API:

// service.js
export function getUser(id) {
  return db.findUser(id); // may throw
}

// controller.js
app.get("/user/:id", async (req, res, next) => {
  try {
    const user = await getUser(req.params.id);
    res.json(user);
  } catch (err) {
    next(err); // Passes the error to the global handler
  }
});

Use a Global Error-Handling Middleware

Centralize error handling using a global middleware. This ensures consistent logging, standardized responses, and avoids duplicated logic:

// error.middleware.js
app.use((err, req, res, next) => {
  console.error(err.stack); // Log full stack trace

  // treat error message / status
  const status = err.status || 500;
  const message = process.env.NODE_ENV === "production"
    ? "Internal server error"
    : err.message;

  res.status(status).send({ error: message });
});

This middleware is responsible for:

  • Logging the complete stack trace (only in safe environments).

  • Formatting the error message based on environment.

  • Returning a consistent structure to the client.

Benefits of This Approach

  • Full and traceable error logs.

  • Clear separation between domain errors and technical failures.

  • Clean, maintainable codebase.

  • Easy integration with monitoring/logging tools (e.g., Logdna, Datadog).

And if trowing an error is truly necessary, consider to use a custom error class. This makes it easier to classify, handle, and respond appropriately in a centralized error handler.

class DatabaseConnectionError extends Error {
  constructor(message) {
    super(message);
    this.name = "DatabaseConnectionError";
    this.status = 503; // Service Unavailable

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DatabaseConnectionError);
    }
  }
}

async function connectToDatabase() {
  const isConnected = false; // Simulate failure
  if (!isConnected) {
    throw new DatabaseConnectionError("Failed to connect to the database");
  }
}

Finally, set up Global handlers for unhandledRejection and uncaughtException

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection:', reason);
  // Optional: Gracefully shut down
  process.exit(1);
});

process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception:', error);
  // Optional: Gracefully shut down
  process.exit(1);
});

These handlers act as a last line of defense, giving you a chance to log and monitor unexpected runtime errors that weren’t caught by your application logic.

Even with proper error handling in place, unexpected failures can still occur, especially in asynchronous code or due to forgotten awaits. To ensure these failures don’t silently crash your application, you should register global process-level error handlers.

⚠️ Important: Use these only to log and safely terminate the process, do not try to recover or continue execution after such errors. Once the application is in an unknown state, the safest option is to restart it (ideally under a process manager like PM2 or Docker with health checks).

And remember...

These are not hard rules, they’re guidelines meant to encourage clearer, more maintainable error handling in JavaScript applications. Every project has its own constraints, and sometimes trade-offs are necessary.

However, adopting even a few of these practices can go a long way in improving observability, reducing hidden bugs, and making your API more robust and predictable.

The goal isn’t to eliminate all errors, but to handle them intentionally, transparently, and consistently.