Skip to content
JavaScript Exception Handling: try, catch, throw, async & Best Practices

16 Minutes

JavaScript Exception Handling: try, catch, throw, async & Best Practices

Fix Bugs Faster! Log Collection Made Easy

Get started

Exceptions are inevitable. It’s how we deal with them that matters. An effective exception handling regime is the difference between an app that only works in sandbox and one that can adapt and scale in the real world.

JavaScript can throw up all kinds of weird and wonderful exceptions, because it runs in inherently unpredictable environments. So we’ve put together this guide to give you a clear, repeatable plan for handling them.

If you’re debugging a specific issue, you can jump to:

But if you prefer a complete understanding of how JavaScript handles exceptions from start to finish, follow the sections in order.

First, what is a JavaScript exception?

A JavaScript exception is a runtime error that interrupts normal program execution.

It typically occurs when:

  • A variable is undefined.
  • A function is called incorrectly.
  • Invalid input is passed.
  • An operation fails unexpectedly.

When an exception happens:

  • The current block of code stops executing.
  • JavaScript searches for an error handler.
  • If no handler exists, it becomes an uncaught exception.

What is JavaScript exception handling?

Exception handling in JavaScript is the process of detecting, managing, and recovering from runtime errors using try, catch, throw, and finally, including asynchronous error handling patterns.

Instead of letting errors terminate execution unpredictably, we can:

  • Isolate risky code.
  • Define recovery logic.
  • Prevent crashes and broken user flows.
  • Clean up resources safely.

Structured error handling turns crashes into controlled outcomes.

try, catch, and finally in JavaScript

try...catch...finally is JavaScript’s built-in exception handling statement. Think of this as the 1-2-3 of your exception handling regime: isolate the risky code, define the response, run the clean up.

try {
  // code that may throw
} catch (error) {
  // handle the error
} finally {
  // always runs
}

The try block

try wraps code that might throw an exception.

  • If no error occurs: all statements inside try execute, and catch is ignored.
  • If an error is thrown: execution inside try stops at that line, and control transfers directly to catch.
try {
  const json = JSON.parse('{"ok": true}');
  console.log(json.ok);
}

The catch block

catch runs only when an exception is thrown inside try.

When this happens, JavaScript passes an Error object to catch, allowing us to inspect what went wrong and decide how to respond.

This object contains useful diagnostic information, especially:

  • error.message → our concise description of the failure.
  • error.stack → the execution trace showing where it originated.
try {
  JSON.parse("{ broken json }");
} catch (error) {
  console.error("Parsing failed:", error.message);
}

The common actions you can build into catch include:

  • Logging the error with additional context.
  • Displaying a fallback UI or message.
  • Returning a safe default.
  • Rethrowing the error if it should be handled higher up.

error.message

This isn’t just another notification: the clarity of the error.message directly affects how quickly we can diagnose the issue. So it needs to clearly explain what went wrong and include enough context to make the issue obvious without needing to reproduce it.

At Bugfender we’ve actually created a set of best-practice guidelines to help our team write strong error.message values. Here it is:

  1. Describe the exact expectation that was violated.
  2. Include relevant identifiers or values.
  3. Avoid generic phrases like “Something went wrong”.
  4. Keep the message concise but specific.
  5. Make it easy to search in logs.

Want some examples? Sure thing. Here are some examples of weak vs. effective messages:

Weak / Vague MessageClear / Actionable Message
"Invalid input""Expected 'email' to be a non-empty string"
"Type error""Expected a number for 'age', received undefined"
"Not found""User not found: id=4821"
"Request failed""GET /api/orders returned 404 for orderId=983"

error.stack

error.stack is crucial to isolating and identifying the problem, showing us exactly where (and how) the failure occurred.

The stack trace is generated automatically by the JavaScript engine and lists the sequence of function calls involved, along with file names and line numbers. Here’s an example structure:

Stack LineWhat It Indicates
TypeError: Cannot read properties of undefinedError type and message
at getUser (user.js:42:15)Exact file and line of the failure
at handleRequest (api.js:18:5)The function that triggered it

In many cases, the top frame shows where the error surfaced – not where it was introduced. The root cause is often a few calls below, especially when invalid data travels through multiple layers.

The finally block

finally executes after try (and catch, if one runs), regardless of the outcome.

It’s the place for logic that must execute unconditionally, and its purpose is deterministic cleanup – not handling errors or making decisions.

Common examples:

  • Stopping a loader.
  • Closing files/connections.
  • Resetting temporary state.
setLoading(true);

try {
  await doWork();
} catch (error) {
  console.error(error);
} finally {
  setLoading(false);
}

JavaScript Error object

As seen in catch, JavaScript represents most runtime failures using the built-in Error object. This provides a structured way to describe and trace problems consistently across the runtime.

An Error instance includes:

  • name → the error type, set automatically by the constructor.
  • message → the human-readable explanation we provide.
  • stack → the call stack generated at the moment the error occurred.

The base Error constructor is appropriate for general-purpose failures when no more specific subtype applies.

const err = new Error("Unexpected condition");

console.log(err.name);    // "Error"
console.log(err.message); // "Unexpected condition"
console.log(err.stack);   // stack trace

Common error types and how to fix them

JavaScript includes several built-in error types that describe specific categories of runtime problems. By isolating the error type, we can get to the root of the problem more quickly and narrow down the potential fixes.

Here are some typical errors and fixes for you to take on board:

Error Type & Typical ScenarioHow to Fix It
TypeError — Calling a method on undefined or using the wrong data typeValidate inputs before use and ensure values are defined and of the expected type.
ReferenceError — Accessing a variable that hasn’t been declaredCheck for typos and ensure the variable is declared and in scope.
RangeError — Providing a value outside permitted boundsValidate numeric limits and boundary conditions before assignment.
SyntaxError — Invalid JavaScript syntaxCorrect the syntax issue (missing brackets, invalid tokens).
URIError — Malformed URI passed to encoding/decoding functionsSanitize and validate URI components before processing them.

Understanding these error categories helps us react appropriately when something fails.

But instead of waiting for built-in errors to occur, we can also enforce our own rules and signal failures deliberately — which is where throw becomes useful.

How to throw exceptions in JavaScript

JavaScript raises errors automatically when something unexpected happens. But we can also signal failures intentionally.

The throw statement allows us to stop execution at a specific point when a required condition isn’t met.

When we call throw:

  • Execution stops immediately
  • The current function exits
  • Control moves to the nearest matching catch block

For example, if a function expects a numeric age, we can enforce that assumption explicitly:

function validateAge(age) {
  if (typeof age !== "number") {
    throw new TypeError("Expected 'age' to be a number");
  }

  return age;
}

This prevents invalid data from continuing deeper into the system, where the failure would be harder to trace.

Throwing errors for input validation

Input validation is one of the most common reasons to use throw.

When data violates an assumption — such as a missing field, invalid format, or out-of-range value — it’s usually better to fail fast with a clear error than to let incorrect data propagate through the system.

Below are common validation scenarios and appropriate error types:

Validation ScenarioExample Error to Throw
Required field is missingthrow new Error("Email is required")
Incorrect data typethrow new TypeError("Email must be a string")
Invalid formatthrow new TypeError("Email must contain '@'")
Value outside allowed rangethrow new RangeError("Password must be at least 8 characters long")

Throwing custom errors

A custom error is a user-defined class that extends JavaScript’s built-in Error object to represent application-specific failures.

While built-in error types describe technical problems, custom errors allow us to label domain or business rule violations explicitly. This makes logs easier to interpret and enables more precise error handling strategies.

Common use cases:

ScenarioWhy Use a Custom Error
Business rule violation (e.g., user already exists)Distinguishes domain logic from technical failures
Authorization failureSeparates permission issues from input or type errors
Payment or subscription state invalidEnables higher-level handling (retry, notify user, etc.)
Application-specific validationMakes logs searchable by domain category

Example: Defining and handling a custom business error

In this example, we model a business rule — “you cannot withdraw more than your balance” — as a custom error. This allows higher-level code to distinguish expected domain failures from unexpected system issues.

class InsufficientBalanceError extends Error {
  constructor(message) {
    super(message); // Initialize built-in Error (message + stack)
    this.name = "InsufficientBalanceError";
  }
}

function withdraw(account, amount) {
  if (amount > account.balance) {
    throw new InsufficientBalanceError(
      `Cannot withdraw ${amount}, available balance is ${account.balance}`
    );
  }

  account.balance -= amount;
  return account.balance;
}

try {
  withdraw({ balance: 100 }, 200);
} catch (error) {
  if (error instanceof InsufficientBalanceError) {
    console.error("Business rule violated:", error.message);
  } else {
    console.error("Unexpected system error:", error);
  }
}

Here, the custom error gives meaning to the failure, making it easier to handle intentionally rather than treating every problem as a generic exception.

Handling asynchronous exceptions in JavaScript

A try...catch block only catches errors that occur synchronously inside the try block.

Modern JavaScript handles most asynchronous operations — such as network requests, timers, or database calls — using Promises.

A Promise is a built-in JavaScript object that represents the future result of an asynchronous operation. Instead of returning a value immediately, it:

  • resolves (success) → delivers the resulting value to the next .then() handler
  • rejects (failure) → delivers an error reason, without throwing synchronously; the error is stored inside the Promise and must be handled using Promise-specific patterns

Because Promise rejections do not behave like synchronous throws, we need specific patterns to handle asynchronous errors.

In practice, there are two correct approaches to handling Promise rejections:

  • Promise chaining with .catch()
  • async/await wrapped in try...catch

Using .catch() with Promises

When working with Promises directly, attach .catch() to handle rejections.

fetch("/api/data")                 // Start an asynchronous HTTP request
  .then((res) => res.json())       // Parse the response body as JSON
  .then((data) => {
    console.log(data);             // Handle successful result
  })
  .catch((error) => {
    console.error("Request failed:", error.message); // Handle rejected Promise
  });

If .catch() is missing, a rejected Promise may become an unhandled promise rejection.

In browsers, this triggers console warnings; in Node.js, it may terminate the process depending on runtime configuration.

Important: fetch() only rejects on network-level failures (no connection, DNS issues, request blocked). An HTTP response like 404 or 500 still resolves the Promise — so it won’t land in .catch().

If you also want 4xx/5xx responses to be treated as failures, you must check response.ok and throw manually (this is a common production pattern):

fetch("/api/data")                           // Start request
  .then((response) => {
    if (!response.ok) {                      // HTTP-level failure (4xx/5xx)
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();                  // Parse JSON only if OK
  })
  .then((data) => {
    console.log(data);                       // Handle successful data
  })
  .catch((error) => {
    console.error("Request failed:", error.message); // Catch network or thrown errors
  });

In larger codebases, this check is often wrapped in a shared helper (or handled by a client library like Axios) so every request consistently treats HTTP errors the same way.

try...catch with async/await

With async/await, try...catch behaves like synchronous error handling – as long as we await the Promise inside the try block.

async function loadData() {
  try {
    const response = await fetch("/api/data");   // Wait for the Promise to settle

    if (!response.ok) {                          // Handle HTTP 4xx/5xx manually
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();          // Await parsing
    return data;                                 // Success path
  } catch (error) {
    console.error("Load failed:", error.message); // Handles rejection or thrown errors
    return null;
  }
}

Key rule: If we do not use await, the Promise is not paused inside the try, and its rejection will not be caught there.

For example, this will not be caught:

try {
  fetch("/api/data"); // Missing await
} catch (error) {
  // This will not run
}

Because without await, the function does not wait for the Promise to reject.

Common async error handling mistakes

Apart from the two mistakes covered above, asynchronous flows introduce their own subtle pitfalls.

Scenario (and why it’s a problem)What to Do Instead
Mixing .then() and async/await in the same function, which makes control flow harder to follow and error propagation inconsistentChoose one style per function. Prefer async/await for readability and centralized try...catch.
Forgetting to return a Promise inside a .then() chain, which breaks the chain and prevents outer .catch() from runningAlways return the Promise from each .then() step so errors propagate correctly.
Catching errors too early in a lower-level function, which hides failures from higher-level logicLet errors bubble up unless that layer can meaningfully recover. Handle them where decisions are made.
Treating every rejection as recoverable, which can allow invalid state to continue silentlyDecide intentionally: retry, fallback, escalate, or rethrow. Not every error should be suppressed.

As your app scales, a strong async strategy will keep propagation predictable and reduce the time you waste when analysing your errors. Fewer blind alleys, more pinpoint solutions.

Uncaught exceptions and production behavior

An uncaught exception is an error that escapes local handling.

When nothing catches it, JavaScript has no safe recovery path, so the runtime falls back to global behavior.

The exact behavior differs between browsers and Node.js, but in both cases it signals that the application is no longer in a reliable state.

Global error handling in browser

Something that we’ve learned the hard way at Bugfender: in the browser, an uncaught exception affects only the current page, but it can still disrupt user experience. When an exception is not handled locally, it typically:

  • Stops the current execution path.
  • Appears in DevTools.
  • Can break UI interactions or leave components in an inconsistent state.

Browsers also report unhandled Promise rejections when a rejected Promise has no error handler attached. These indicate asynchronous failures that were never properly handled.

Global listeners can be registered for logging and monitoring:

window.onerror = function (message, source, lineno, colno, error) {
  console.error("Global error:", message);
};

window.onunhandledrejection = function (event) {
  console.error("Unhandled rejection:", event.reason);
};

These handlers are useful for reporting errors, but significantly, they do not restore application state.

Global error handling in Node.js

Node.js is great for extending the possibilities of JavaScript, but an uncaught exception is particularly savage in this environment. When an error is not handled locally, it can terminate the process, leave open connections or incomplete operations and corrupt in-memory state if it’s allowed to fester.

How do we stop this? Well like the browsers we covered earlier, Node.js reports unhandled Promise rejections when a rejected Promise has no handler attached. Global listeners can be registered like this:

process.on("uncaughtException", (error) => {
  console.error("Uncaught exception:", error);
});

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

In production systems, uncaught exceptions are often treated as fatal. Instead of attempting recovery, the process is logged and restarted cleanly using a process manager.

Logging and monitoring uncaught errors

When an error reaches the global level, the only reliable response is visibility.

  • Without production logging → you see a crash, but lack the stack trace and runtime context needed to diagnose it.
  • With production logging → you capture the error message, stack trace, and real environment data, turning failures into actionable fixes.

If you’ll forgive the slightly corny line, production logging makes uncaught exceptions traceable instead of mysterious.

Tools like Bugfender let you capture production logs directly from real user devices, giving you visibility into errors that never surface in local testing.

You can try Bugfender for free and start monitoring real-world failures as they happen.

An example of production error monitoring in Bugfender.

An example of production error monitoring in Bugfender.

A quick JavaScript exception handling checklist

Again, I want to share a checklist we’ve put together at Bugfender. Ideally, you should be able to check all of these before you ship:

  • [ ] We throw Error objects (not strings or numbers)
  • [ ] We use specific error types when applicable
  • [ ] Business rule violations use custom errors
  • [ ] We do not swallow errors silently
  • [ ] If we catch and rethrow, we preserve the original error
  • [ ] Every Promise chain ends with .catch()
  • [ ] Every async block awaits Promises inside try
  • [ ] We do not mix .then() and async/await in the same flow
  • [ ] Uncaught exceptions are logged globally
  • [ ] Unhandled Promise rejections are monitored in production

If multiple items can’t be checked confidently, your exception handling strategy likely has gaps.

FAQs about JavaScript exception handling

Can I rethrow an exception after catching it?

Yes. You can catch an error, add context, and rethrow it:

try {
  riskyOperation();
} catch (error) {
  console.error("Database failed:", error);
  throw error; // rethrow
}

Rethrowing is useful when you want higher layers to decide how to handle the failure.

What is an unhandled Promise rejection?

An unhandled Promise rejection occurs when a Promise fails and no .catch() handler is attached.

In browsers, it appears as an “Unhandled Promise Rejection” warning.

In Node.js, it may terminate the process depending on runtime settings.

Always attach .catch() or use await inside try...catch.

Should I catch errors at every level?

Short answer: No. Catching errors too early can actually be counter-productive as it will hide useful debugging information.

Instead:

  • Throw errors where they happen.
  • Catch them at architectural boundaries (API layer, UI layer, job runner, etc).
  • Handle errors where meaningful recovery or logging can occur.

Does finally override return statements?

Yes, it can.

If finally returns a value, it overrides returns from try or catch.

This behavior can be surprising and should be used carefully.

Can I create my own error types?

Yes, by extending the Error class:

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

Custom error classes improve clarity and allow more precise error filtering.

Do all JavaScript APIs throw exceptions?

No. Some APIs (like fetch) resolve successfully even for HTTP errors (e.g., 404 or 500).

In those cases, you must check response properties like res.ok manually.

Not all failures automatically throw exceptions.

Final Thoughts

JavaScript exception handling is not about catching every error. It is about designing predictable failure paths.

When errors are thrown intentionally, handled at the right layer, and monitored in production, they stop being crashes and become signals.

So treat failures not as a breakdown in your architecture, but as part of your architecture, but as a key part of it. When things do break, you’ll know exactly where to look.

Happy debugging!

Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Aleix Ventayol

Aleix Ventayol is CEO and co-founder of Bugfender, with 20 years' experience building apps and solutions for clients like AVG, Qustodio, Primavera Sound and Levi's. As a former CTO and full-stack developer, Aleix is passionate about building tools that solve the real problems of app development and help teams build better software.

Join thousands of developers
and start fixing bugs faster than ever.