JavaScript try/catch Tutorial: Advanced Error Handling

Go beyond basic try/catch in JavaScript. Learn advanced error handling patterns including typed error checking, rethrowing, finally semantics, error boundaries, global handlers, and building a robust error handling strategy.

JavaScriptintermediate
13 min read

Most developers know the basic try/catch block. Advanced error handling goes much further: distinguishing error types, rethrowing appropriately, using finally correctly, building error hierarchies with custom classes, and maintaining global safety nets. This guide covers the patterns that separate fragile code from robust, debuggable applications.

The try/catch/finally Structure

javascriptjavascript
try {
  // Code that might throw
  const data = JSON.parse(rawInput);
  processData(data);
} catch (error) {
  // Runs if try block throws
  console.error("Parse or process error:", error.message);
} finally {
  // ALWAYS runs — after try OR catch
  cleanup();
}

All three blocks are optional in combination, but try must be paired with at least one of catch or finally.

The finally Block Semantics

finally always executes, even if:

  • The try block returns a value
  • The catch block returns a value
  • The catch block throws
javascriptjavascript
function example() {
  try {
    return "from try";     // finally still runs!
  } finally {
    console.log("finally runs"); // Logs before returning
    // NOT returning here — the "from try" value is preserved
  }
}
console.log(example()); // "finally runs", then "from try"
 
// If finally DOES return, it overrides the try/catch return:
function overrideExample() {
  try {
    return "from try";
  } finally {
    return "from finally"; // Overrides!
  }
}
console.log(overrideExample()); // "from finally"

Best practice: Never return or throw from finally — it silently swallows errors or overrides return values. Use finally only for side effects (cleanup, logging, spinner hiding).

Typed Error Handling

Catch-all catch blocks treat every error the same. Typed error handling lets you respond differently to different failures:

javascriptjavascript
// Using instanceof to check error type
try {
  const user = JSON.parse(input);
  await saveUser(user);
} catch (error) {
  if (error instanceof SyntaxError) {
    // Input was malformed JSON
    showValidationMessage("Invalid JSON format");
  } else if (error instanceof NetworkError) {
    // Save failed due to network
    queueForRetry(user);
  } else if (error instanceof AuthError) {
    // Missing permissions
    redirectToLogin();
  } else {
    // Unknown error — log and rethrow
    logError(error);
    throw error;
  }
}

This pattern — handle known types, rethrow unknowns — is fundamental to writing maintainable error handling.

Rethrowing: The Critical Pattern

A common mistake is swallowing errors you cannot handle:

javascriptjavascript
// BAD: swallowing unknown errors
try {
  riskyOperation();
} catch (error) {
  console.log("Something went wrong");
  // Error is silently consumed — caller has no idea it failed
}
 
// GOOD: only handle what you understand, rethrow the rest
try {
  riskyOperation();
} catch (error) {
  if (error instanceof KnownError) {
    handleGracefully(error);
  } else {
    throw error; // Rethrow — let a higher layer handle it
  }
}

Rethrowing preserves the original stack trace. Re-wrapping is sometimes appropriate when adding context:

javascriptjavascript
try {
  await db.query(sql, params);
} catch (error) {
  // Add context and rethrow
  throw new DatabaseError(`Query failed: ${sql}`, { cause: error });
}
// The caller sees a meaningful DatabaseError with the original cause attached

The cause option in the Error constructor (ES2022) is the standard way to chain errors while preserving the original:

javascriptjavascript
const dbError = new Error("Query failed", { cause: originalError });
console.log(dbError.cause); // The original database error
console.log(dbError.cause.stack); // Original stack trace

Custom Error Classes

Custom error classes enable typed error handling with an organized hierarchy:

javascriptjavascript
// Base application error
class AppError extends Error {
  constructor(message, options = {}) {
    super(message, options);
    this.name = this.constructor.name; // "AppError"
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor); // V8 optimization
    }
  }
}
 
// Domain-specific errors
class ValidationError extends AppError {
  constructor(message, field) {
    super(message);
    this.field = field;
  }
}
 
class NetworkError extends AppError {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
  }
}
 
class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} with id ${id} not found`);
    this.resource = resource;
    this.id = id;
  }
}
 
// Usage
try {
  validate(input);
  await save(data);
} catch (err) {
  if (err instanceof ValidationError) {
    return res.status(400).json({ error: err.message, field: err.field });
  }
  if (err instanceof NotFoundError) {
    return res.status(404).json({ error: err.message });
  }
  if (err instanceof NetworkError) {
    return res.status(502).json({ error: "Upstream service unavailable" });
  }
  // Unknown error: 500
  logError(err);
  return res.status(500).json({ error: "Internal server error" });
}

For more on building and extending custom errors, see creating custom errors in JavaScript and extending the Error class.

Error Handling Strategy Table

Error TypeAction
Expected domain errors (validation, not found)Handle at site, return user-facing response
Recoverable transient errors (network timeout)Retry with backoff, then fail gracefully
Unrecoverable errors (auth failures)Fail fast, inform user, clean up state
Programming errors (TypeError, ReferenceError)Do NOT catch — let them surface for debugging
Unknown errorsLog with full context + stack, rethrow

Global Error Handlers

Individual try/catch blocks handle expected errors. Global handlers are the safety net:

javascriptjavascript
// Browser: uncaught synchronous errors
window.onerror = function (message, source, lineno, colno, error) {
  logToServer({
    type: "uncaught_error",
    message,
    source,
    line: lineno,
    stack: error?.stack,
  });
  return false; // false = let browser show default error, true = suppress
};
 
// Browser: unhandled Promise rejections
window.addEventListener("unhandledrejection", (event) => {
  logToServer({
    type: "unhandled_rejection",
    reason: event.reason?.message || String(event.reason),
    stack: event.reason?.stack,
  });
});
 
// Node.js equivalents:
process.on("uncaughtException", (error) => {
  logger.fatal(error);
  process.exit(1); // Always exit after uncaughtException
});
 
process.on("unhandledRejection", (reason, promise) => {
  logger.error("Unhandled rejection:", reason);
  // Consider process.exit(1) in production
});

Error Serialization for Logging

Standard JSON.stringify(error) produces {} since Error properties are non-enumerable:

javascriptjavascript
// WRONG: Error properties are non-enumerable
console.log(JSON.stringify(new Error("oops"))); // "{}"
 
// CORRECT: explicitly serialize fields
function serializeError(error) {
  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    cause: error.cause ? serializeError(error.cause) : undefined,
    // Custom fields from AppError subclasses:
    ...(error.field && { field: error.field }),
    ...(error.statusCode && { statusCode: error.statusCode }),
  };
}
 
logToServer(serializeError(error));

Try/Catch in async Functions

In async functions, await causes a rejected Promise to throw at the await line, making try/catch work naturally:

javascriptjavascript
async function loadData(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new NetworkError(`HTTP ${response.status}`, response.status);
    }
    return await response.json();
  } catch (err) {
    if (err instanceof NetworkError && err.statusCode === 404) {
      return null; // Graceful not-found
    }
    throw err; // Rethrow everything else
  }
}

For full coverage of async-specific rejection handling, see handling async errors with try/catch.

Rune AI

Rune AI

Key Insights

  • Rethrow errors you cannot handle: Never swallow an error silently — if you cannot handle a specific error type, rethrow it so a higher layer can
  • Use instanceof for typed error dispatch: Checking error types enables specific responses to specific failures rather than one-size-fits-all error handling
  • finally is for cleanup, not control flow: Never return or throw from finally — it silently overrides try/catch behavior and creates confusing bugs
  • Error({ cause }) chains errors with context: ES2022 new Error("message", { cause: originalErr }) preserves the full error chain for debugging without losing the original stack trace
  • Global handlers are safety nets, not handlers: Register onerror and unhandledrejection to catch what slips through, but they are not a substitute for proper per-operation error handling
RunePowered by Rune AI

Frequently Asked Questions

Can I access the error variable outside the catch block?

No. The caught error variable is scoped to the catch block. If you need it later, assign it to a let variable declared before the try: ```javascript let lastError; try { ... } catch (err) { lastError = err; } // lastError is accessible here ```

Should I catch Error or catch everything?

JavaScript lets you throw (and catch) any value: `throw "string"`, `throw 42`, `throw null`. The convention is to always throw Error instances (or subclasses), which gives a stack trace. When catching, assume the thrown value is an Error but guard: `error?.message ?? String(error)`.

Does catch change the error in the catch block affect the rethrow?

No. Rethrowing with `throw error` rethrows the original caught object. Mutating `error.message` before rethrowing would alter the message, but the stack trace is already captured and is not modified by mutation.

When is it appropriate to NOT use try/catch?

For programming errors like `TypeError` (accessing a property on `undefined`) or `ReferenceError` (undeclared variable), do not catch them — they indicate bugs in the code, not runtime conditions. Let them surface so they can be fixed. Catching them hides bugs.

Is try/catch performance-intensive?

Modern JavaScript engines optimize try/catch well. The overhead is negligible in normal execution paths. It was historically a concern in V8 years ago, but that is no longer true. Write correct, readable error handling without worrying about the micro-performance of try/catch.

Conclusion

Advanced error handling in JavaScript is built on four pillars: typed errors (custom classes enabling instanceof checks), disciplined rethrowing (handling what you understand, rethrowing the rest), proper finally usage (cleanup only, no return or throw), and global safety nets (onerror, unhandledrejection). Combined with clear custom error hierarchies, these patterns make errors visible, debuggable, and recoverable.