Handling Errors in JavaScript

Handling Errors in JavaScript

Handling Errors in JavaScript

When I first started writing JavaScript professionally, I treated errors like an afterthought. I’d write my happy-path code, ship it, and only think about error handling after something broke in production and I got a panicked message from a client. Over the years I’ve completely flipped that mindset. Now I think about what can go wrong before I write the code that does the thing. In this article, I want to walk you through everything I’ve learned about handling errors in JavaScript — from the absolute basics of try/catch to the internal mechanics of how the engine propagates errors, all the way to production-grade error handling strategies I use in real apps today.

Why Error Handling Matters

Every application, no matter how well written, will encounter unexpected situations: a network request fails, a user types text into a field that expects a number, an API returns malformed JSON, or a third-party script throws because it wasn’t loaded yet. If I don’t handle these cases, my application crashes, users see a blank white screen, and I lose their trust. Good error handling is what separates a hobby project from production-grade software.

The Basics: try, catch, finally

JavaScript gives me a built-in mechanism for handling runtime errors: the try...catch...finally statement.

try {
  const result = riskyOperation();
  console.log(result);
} catch (error) {
  console.error("Something went wrong:", error.message);
} finally {
  console.log("This always runs, error or not.");
}

Here’s how I think about each block:

Output Example

try {
  null.foo();
} catch (error) {
  console.error(error.message);
}
// Output: Cannot read properties of null (reading 'foo')

The Error Object and Its Anatomy

When JavaScript throws an error, it creates an Error object (or a subclass of it). I always inspect these three properties:

PropertyDescription
nameThe type of error, e.g. TypeError, RangeError, SyntaxError
messageA human-readable description of what went wrong
stackA stack trace showing where the error occurred (non-standard but supported everywhere)
try {
  JSON.parse("{ invalid json }");
} catch (error) {
  console.log(error.name);    // SyntaxError
  console.log(error.message); // Unexpected token i in JSON at position 2
  console.log(error.stack);   // Full stack trace
}

Built-in Error Types

JavaScript ships with several built-in error constructors, and I’ve learned to recognize each one because it tells me instantly what category of bug I’m dealing with:

Error TypeWhen It’s Thrown
ErrorGeneric base error, or when I create custom errors
TypeErrorWhen a value isn’t of the expected type (e.g., calling a non-function)
RangeErrorWhen a number is outside an allowed range (e.g., invalid array length)
ReferenceErrorWhen referencing a variable that doesn’t exist
SyntaxErrorWhen code can’t be parsed (often from eval() or JSON.parse())
URIErrorWhen global URI functions like decodeURIComponent() are misused
EvalErrorLegacy, rarely thrown in modern engines

Throwing My Own Errors

I don’t have to wait for the engine to throw errors — I can throw my own using the throw keyword. Technically, I can throw any value in JavaScript (a string, a number, an object), but I always throw actual Error instances because they carry a stack trace, which is invaluable for debugging.

function withdraw(balance, amount) {
  if (amount > balance) {
    throw new Error("Insufficient funds");
  }
  return balance - amount;
}

try {
  withdraw(100, 150);
} catch (error) {
  console.error(error.message); // Insufficient funds
}

Custom Error Classes

In real-world applications, I almost always create custom error classes by extending Error. This lets me distinguish between different failure modes programmatically instead of parsing error message strings (which is fragile and something I actively avoid).

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

class NetworkError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = "NetworkError";
    this.statusCode = statusCode;
  }
}

function validateAge(age) {
  if (age < 0) {
    throw new ValidationError("Age cannot be negative", "age");
  }
}

try {
  validateAge(-5);
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(`Validation failed on field: ${error.field}`);
  } else {
    throw error; // rethrow anything I don't know how to handle
  }
}

I want to highlight that last else branch — rethrowing unknown errors instead of silently swallowing them. This is one of the most important habits I’ve built. Catching every error and doing nothing with it hides bugs and makes debugging a nightmare later.

Handling Errors in Asynchronous Code

Error handling gets more nuanced once asynchronous code enters the picture, because a regular try/catch around a callback-based async call won’t catch errors that happen later, in a different turn of the event loop.

Callbacks

function fetchData(callback) {
  setTimeout(() => {
    try {
      throw new Error("Failed to fetch");
    } catch (error) {
      callback(error, null);
    }
  }, 1000);
}

fetchData((error, data) => {
  if (error) {
    console.error("Error in callback:", error.message);
    return;
  }
  console.log(data);
});

Notice I can’t wrap fetchData(callback) itself in a try/catch and expect it to catch errors thrown inside the setTimeout — by the time that code runs, the surrounding try/catch has already finished executing. This trips up a lot of beginners.

Promises

With Promises, I use .catch() to handle rejections:

function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error("Network timeout"));
    }, 1000);
  });
}

fetchData()
  .then((data) => console.log(data))
  .catch((error) => console.error("Caught:", error.message));

Async/Await

This is the pattern I use most in modern code because try/catch works exactly the way I intuitively expect:

async function loadUser() {
  try {
    const response = await fetch("https://api.example.com/user");
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    const user = await response.json();
    return user;
  } catch (error) {
    console.error("Failed to load user:", error.message);
    throw error; // let the caller decide what to do next
  }
}

Global Error Handling

I always add a safety net at the application level to catch anything that slips through my local try/catch blocks.

In the Browser

window.addEventListener("error", (event) => {
  console.error("Uncaught error:", event.message, event.filename, event.lineno);
});

window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled promise rejection:", event.reason);
});

In Node.js

process.on("uncaughtException", (error) => {
  console.error("Uncaught exception:", error);
  process.exit(1); // I always exit after an uncaught exception — the app is in an unknown state
});

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

I treat these global handlers as a last line of defense, not a substitute for proper local error handling. If I’m relying on them to catch everything, I’ve already lost visibility into where things are actually failing.

Internal Working: How Error Propagation Works

Under the hood, when JavaScript throws an error, the engine unwinds the call stack looking for the nearest enclosing try/catch block. If it doesn’t find one, the error propagates all the way up to the global scope, which is why unhandled errors in the browser show up in the console and, in Node.js, can crash the process.

This unwinding process matters for performance too — engines like V8 optimize functions differently when they contain try/catch blocks versus when they don’t, although modern V8 versions have significantly closed this gap compared to older JavaScript engines. I don’t avoid try/catch for performance reasons anymore; the difference is negligible in most real applications.

For asynchronous code, the event loop plays a role. When an error is thrown inside a .then() callback or after an await, it’s converted into a rejected Promise. That rejection then propagates through the Promise chain until it hits a .catch() or an await wrapped in try/catch. If it never does, it becomes an “unhandled rejection,” which is why the unhandledrejection event exists.

Practical, Real-World Patterns

Retry Logic with Exponential Backoff

async function fetchWithRetry(url, retries = 3, delay = 500) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`Status ${response.status}`);
      return await response.json();
    } catch (error) {
      if (attempt === retries) throw error;
      console.warn(`Attempt ${attempt} failed, retrying...`);
      await new Promise((r) => setTimeout(r, delay * attempt));
    }
  }
}

Wrapping Errors with Context

I like to add context as an error bubbles up so I know exactly where it originated without losing the original cause.

async function getUserOrders(userId) {
  try {
    return await fetchOrders(userId);
  } catch (error) {
    throw new Error(`Failed to load orders for user ${userId}: ${error.message}`, {
      cause: error,
    });
  }
}

The second argument, { cause: error }, is the modern Error cause chaining feature. It lets me inspect error.cause later without losing the original stack trace.

Debugging Tips I Rely On

Common Mistakes to Avoid

  1. Swallowing errors silently — an empty catch {} block is one of the most dangerous things I can write.
  2. Catching too broadly — wrapping huge chunks of code in one try/catch makes it hard to know what actually failed.
  3. Forgetting await inside try — if I don’t await a promise inside a try block, the catch won’t catch its rejection.
  4. Not handling Promise rejections — every promise chain needs a .catch() or must be inside a try/catch with await.
  5. Throwing non-Error values — throw "Something broke" loses the stack trace that makes debugging possible.

Security Considerations

I’m careful never to leak sensitive information in error messages that get shown to end users or returned from APIs. Stack traces, database queries, and internal file paths should never reach the client in production. I always log the full error server-side but return a generic, safe message to the user.

app.use((error, req, res, next) => {
  console.error(error.stack); // full detail, server logs only
  res.status(500).json({ message: "Something went wrong. Please try again." });
});

FAQs

Q: What’s the difference between throw and console.error? A: console.error just logs a message to the console — it doesn’t stop execution. throw actually interrupts the normal flow of the program and starts the process of looking for a catch block.

Q: Can I catch a SyntaxError from my own code? A: No. If the JavaScript file itself has a syntax error, it fails to parse and nothing runs at all, so there’s no try/catch to catch it. You can only catch SyntaxError from things like JSON.parse() or eval().

Q: Should I use finally to hide loading spinners? A: Yes, this is one of my favorite uses for finally — it runs regardless of success or failure, so it’s perfect for cleanup logic like hiding loaders or closing modals.

Q: Is it bad to use try/catch for control flow? A: Generally yes. Errors should represent exceptional situations, not routine logic. Using exceptions for regular control flow makes code harder to read and can hurt performance in some engines.

Q: What is AggregateError? A: It’s a built-in error type introduced with Promise.any() that bundles multiple errors together when all promises in the group reject.

Summary and Key Takeaways

Error handling isn’t an afterthought — it’s a core part of how I design software now. Here’s what I always keep in mind:

Getting comfortable with error handling early on will save you countless hours of confused debugging later, and it’s one of the clearest signs of a mature JavaScript codebase.

References

Exit mobile version