JavaScript

Error Handling

20 Questions

The try/catch/finally statement handles runtime errors gracefully. The try block wraps code that may throw an error. The catch block receives the error object and handles it. The finally block always executes regardless of success or failure — making it ideal for cleanup tasks like closing connections.
try {
  const data = JSON.parse(invalidJson);
} catch (error) {
  console.error('Parse failed:', error.message);
} finally {
  console.log('Always runs');
}

// finally runs even with return
function getData() {
  try {
    return 'data';
  } finally {
    console.log('Cleanup'); // runs before return
  }
}

// try/finally without catch
function lockAndProcess() {
  lock.acquire();
  try {
    processData(); // if this throws, finally still runs
  } finally {
    lock.release(); // always release the lock
  }
}

// Nested try/catch
try {
  try {
    throw new Error('inner');
  } catch (e) {
    console.log('Inner catch:', e.message);
    throw e; // rethrow to outer
  }
} catch (e) {
  console.log('Outer catch:', e.message);
}
You can omit catch if finally is present, but you cannot omit both. The finally block runs even if the try or catch block contains a return, break, or continue statement — it always gets the last word before control leaves the statement.

Why it matters: try/catch/finally is the fundamental error-handling construct. Understanding when finally runs (always, including after return) is a common interview gotcha. Misunderstanding it leads to resource leaks and surprising return values.

Real applications: Database connection cleanup in finally blocks, unlocking mutexes/locks regardless of success or failure, closing file handles after read operations, logging timing information after async operations complete, and ensuring UI loading states are cleared after any API outcome.

Common mistakes: Returning a value in finally (it overrides the return in try/catch — a common gotcha), swallowing errors with empty catch blocks (always log or rethrow), wrapping too much code in a single try block (hard to know what specific line threw), and not knowing that finally doesn't run after process.exit() in Node.js.

Creating custom Error classes by extending the built-in Error class lets you define specific error types with meaningful names and additional properties. This enables targeted error handling using instanceof checks in catch blocks, making your error handling more precise and maintainable.
class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
  }
}

class NotFoundError extends Error {
  constructor(resource) {
    super(resource + ' not found');
    this.name = 'NotFoundError';
    this.status = 404;
  }
}

class AuthError extends Error {
  constructor(message) {
    super(message);
    this.name = 'AuthError';
    this.status = 401;
  }
}

try {
  throw new ValidationError('email', 'Invalid email');
} catch (e) {
  if (e instanceof ValidationError) {
    console.log('Field:', e.field); // "email"
  } else if (e instanceof NotFoundError) {
    console.log('Status:', e.status);
  } else {
    throw e; // rethrow unknown errors
  }
}
Always set this.name in the constructor to match the class name — this ensures stack traces and error logs display the correct error type. Custom errors preserve the stack trace automatically through the super() call. Use specific error types for different failure categories rather than generic Error objects.

Why it matters: Custom error classes let callers use instanceof to handle specific error types differently — network errors vs validation errors vs authorization errors each deserve different recovery strategies.

Real applications: HTTP client libraries throwing NetworkError, AuthError, and RateLimitError subclasses, form validation throwing ValidationError with per-field details, domain model violations throwing DomainError, and API layers wrapping raw errors in ApiError(message, status, cause) with structured context.

Common mistakes: Not setting this.name = this.constructor.name (stack traces show "Error" instead of your class name), forgetting to call super(message) (the message property won't be set), extending Error but not adding useful additional properties, and using typeof/message string matching instead of instanceof to distinguish error types.

JavaScript provides several built-in Error types that represent different categories of runtime problems. TypeError occurs when a value is not the expected type, ReferenceError when accessing an undeclared variable, SyntaxError for invalid code, and RangeError when a value falls outside an allowed range.
// TypeError — wrong type or calling non-function
null.toString();           // TypeError
undefined.map(x => x);    // TypeError
(42).toUpperCase();        // TypeError

// ReferenceError — variable not declared
console.log(foo);          // ReferenceError: foo is not defined

// SyntaxError — invalid code (caught at parse time)
// eval('if(');             // SyntaxError: Unexpected end of input

// RangeError — value out of valid range
new Array(-1);             // RangeError: Invalid array length
(1).toFixed(200);          // RangeError

// URIError — malformed URI
decodeURIComponent('%');   // URIError

// All error types share common properties
try {
  null.method();
} catch (e) {
  console.log(e.name);    // "TypeError"
  console.log(e.message); // "Cannot read properties of null"
  console.log(e.stack);   // full stack trace
}
SyntaxError is unique because it is typically caught at parse time — the script won't run at all unless the syntax error is inside eval() or new Function(). The EvalError type exists for historical reasons but is rarely encountered in modern JavaScript.

Why it matters: Knowing which built-in error type to use makes error messages more informative and lets callers handle them specifically. Using TypeError for wrong argument type, RangeError for out of bounds, and URIError for invalid URLs is part of standard JavaScript API design.

Real applications: Throwing TypeError when a function receives the wrong type of argument, RangeError for invalid array lengths or numeric ranges, ReferenceError in custom interpreters or sandboxed environments, and SyntaxError inside dynamic code evaluators.

Common mistakes: Throwing a generic Error when a more specific type better describes the problem, catching specific error types without re-throwing unknowns (swallows programmer errors), and using SyntaxError for user-facing validation messages (it's a developer-facing type — use a custom ValidationError instead).

The throw statement stops normal execution and passes control to the nearest catch block in the call stack. You can throw any value, but throwing Error objects is best practice because they include a stack trace, error name, and message — essential for debugging.
// Throw an Error object (recommended)
throw new Error('Something went wrong');

// Throw custom error
throw new ValidationError('age', 'Must be positive');

// You can throw anything (not recommended)
throw 'error string';
throw 404;
throw { code: 'INVALID' };

// Rethrow after logging
try {
  riskyOperation();
} catch (e) {
  console.error(e);
  throw e;  // rethrow for caller to handle
}

// Conditional throwing
function divide(a, b) {
  if (b === 0) throw new RangeError('Division by zero');
  return a / b;
}

// throw is an expression in some contexts
const value = data ?? (() => { throw new Error('No data'); })();
Always throw Error instances rather than primitive values — strings and numbers lack stack traces, making debugging extremely difficult. When rethrowing errors, consider wrapping them in a new error with additional context using the cause property: throw new Error('Load failed', { cause: originalError }).

Why it matters: The throw statement works with any value but Error objects are the only kind that carry stack traces. Using throw correctly, including conditional rethrowing in catch blocks, is a key skill for building robust error handling pipelines.

Real applications: Guard clauses that throw on invalid input, rethrowing unexpected errors in catch blocks after handling expected ones, enriching errors with context before rethrowing (throw new ApiError('User load failed', { cause: err })), and implementing assertion utilities that throw on violated invariants.

Common mistakes: Throwing strings (throw 'something went wrong') — they have no stack trace, throwing inside a finally block (can mask the original error), catching all errors and throwing a new one without the cause property (loses original stack trace), and forgetting that thrown values propagate outward until caught or crashing the program.

Wrap await calls in try/catch to handle errors in async functions. For multiple independent async operations, use Promise.allSettled to avoid short-circuiting on the first failure. Unhandled rejections in async functions become unhandled promise rejections if not caught.
async function fetchUser(id) {
  try {
    const res = await fetch('/api/user/' + id);
    if (!res.ok) throw new Error('HTTP ' + res.status);
    return await res.json();
  } catch (error) {
    console.error('Fetch failed:', error);
    return null;
  }
}

// Multiple independent calls
const results = await Promise.allSettled([
  fetchUser(1),
  fetchUser(2)
]);
results.forEach(r => {
  if (r.status === 'fulfilled') console.log(r.value);
  else console.log('Failed:', r.reason);
});

// Error cause chaining (ES2022)
async function loadProfile(id) {
  try {
    return await fetchUser(id);
  } catch (err) {
    throw new Error('Profile load failed', { cause: err });
  }
}
When using Promise.all(), a single rejection rejects the entire batch — use Promise.allSettled() when you want all results regardless of individual failures. The cause property (ES2022) lets you chain errors to preserve the original failure context while adding higher-level meaning.

Why it matters: Async code without error handling is the most common source of unnoticed failures in JavaScript apps. Understanding how try/catch interacts with async/await (and where it doesn't work) prevents silent data loss and broken UI states.

Real applications: Wrapping fetch/axios calls in try/catch for network errors, handling database query failures in Node APIs, catching login errors to show user-friendly messages, building retry logic around failed async operations, and catching initialization failures at app startup.

Common mistakes: Forgetting await inside a try block (the Promise rejects asynchronously, bypassing the catch), not knowing that async functions always return a Promise (even if they throw — the throw becomes a rejection), and using .catch() on an awaited value you've already handled with try/catch (double handling).

window.onerror is a global handler that catches uncaught runtime errors across your entire application. It receives five arguments: the error message, source file URL, line number, column number, and the error object itself. Returning true suppresses the default browser error logging in the console.
window.onerror = function(message, source, line, col, error) {
  console.log('Error:', message);
  console.log('Source:', source + ':' + line + ':' + col);
  console.log('Stack:', error?.stack);

  // Send to error tracking service
  sendToService({ message, source, line, col, stack: error?.stack });

  return true;  // suppress default console error
};

// Modern alternative — addEventListener
window.addEventListener('error', (event) => {
  console.log('Error:', event.message);
  console.log('File:', event.filename);
  console.log('Line:', event.lineno);
  event.preventDefault(); // suppress default
});

// Note: does NOT catch promise rejections
// Use 'unhandledrejection' for those

// Also does NOT catch errors in async callbacks
// or cross-origin script errors (unless CORS headers set)
The window.onerror handler should be set as early as possible in your application — ideally in the <head> section before other scripts. Note that cross-origin scripts only report "Script error" unless the script tag has crossorigin="anonymous" and the server sends appropriate CORS headers.

Why it matters: Global error handlers are the last line of defense for catching errors that slip through specific try/catch blocks. Setting them up correctly is a production requirement for any user-facing application that needs error visibility.

Real applications: Logging unexpected client-side errors to Sentry or Datadog, showing a user-friendly error page for critical failures, tracking JavaScript error rates as an SLA metric, cross-origin script debugging with CORS headers, and reporting unhandled errors in browser extensions.

Common mistakes: Setting window.onerror too late (some errors happen during initial script execution), not adding crossorigin="anonymous" to script tags (third-party script errors always show "Script error" without it), returning true from onerror to suppress default browser error reporting (hides issues during development), and not distinguishing between window.onerror (all runtime errors) and unhandledrejection (Promise rejections only).

The unhandledrejection event fires on the window object when a Promise is rejected and no .catch() handler is attached. This is essential for catching errors that would otherwise be silently swallowed, especially in complex async workflows where a rejection handler might be accidentally omitted.
window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled rejection:', event.reason);

  // Prevent default browser warning
  event.preventDefault();

  // Log to error service
  logError(event.reason);
});

// This rejection will be caught by the handler above
Promise.reject(new Error('Oops'));

// This will NOT trigger it (has .catch)
Promise.reject(new Error('Handled')).catch(e => {});

// rejectionhandled — fires when a rejection is later handled
window.addEventListener('rejectionhandled', (event) => {
  console.log('Rejection was handled late:', event.reason);
});

// Node.js equivalent
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection:', reason);
});
In Node.js, unhandled promise rejections will terminate the process by default (since Node 15+). The rejectionhandled event is the companion to unhandledrejection — it fires when a previously unhandled rejection later gets a handler attached, which can happen with delayed .catch() calls.

Why it matters: Unhandled Promise rejections are the async equivalent of uncaught exceptions. Since they don't crash the browser tab immediately, they're often silently ignored — leading to subtle bugs where operations fail without any user-visible indication.

Real applications: Production monitoring of unhandled rejections in SPA frameworks, Node.js process-level rejection handlers in microservices, detecting Promise-based API calls that were never .catch()'d, and implementing async error dashboards that track failure rates by type.

Common mistakes: Trusting the browser won't crash without handling Promise rejections (it logs a warning but execution continues, masking bugs), not adding a process-level handler in Node.js (process exits with code 1 since Node 15+), and adding a .catch() handler on a different tick than the rejection occurs (correctly handles it but triggers rejectionhandled).

The stack property of an Error object contains a string showing the call stack at the point the error was created. It displays function names, file paths, and line numbers — forming a trail from the error back to its origin. This is the most important tool for debugging runtime errors.
function c() { throw new Error('fail'); }
function b() { c(); }
function a() { b(); }

try {
  a();
} catch (e) {
  console.log(e.stack);
  // Error: fail
  //   at c (script.js:1)
  //   at b (script.js:2)
  //   at a (script.js:3)
}

// Capture stack without throwing
const trace = new Error('debug');
console.log(trace.stack); // stack at creation point

// Custom stack trace starting point (V8)
function apiFunction() {
  const err = new Error('API Error');
  Error.captureStackTrace(err, apiFunction);
  throw err; // stack starts from caller, not apiFunction
}

// Stack trace with async code
async function fetchData() {
  const response = await fetch('/api');
  throw new Error('parse error');
  // stack shows async call chain
}
Stack traces read bottom to top — the deepest function (where the error occurred) is at the top. Error.captureStackTrace() (V8 engines) lets you control where the stack trace starts, which is useful in libraries to hide internal implementation details from the trace.

Why it matters: Reading stack traces is an essential debugging skill. Understanding the trace format (function name, file, line, column) and how async operations appear in traces is critical for tracking down production errors efficiently.

Real applications: Reading Node.js server error logs, interpreting React or Vue error boundaries, debugging source-mapped production errors in Sentry, understanding async stack traces in Chrome DevTools, and building custom error reporters that extract file/line info from stack strings.

Common mistakes: Reading a stack trace top to bottom (should read bottom to top for the call sequence), not enabling source maps for production builds (minified function names are useless), ignoring the column number in the trace (important for minified files with long lines), and not using Error.stack in logging (just printing the message loses all trace context).

When an error is thrown, JavaScript unwinds the call stack looking for a catch block. Each function in the chain exits immediately as control moves upward. If no catch block is found anywhere in the stack, the error becomes an uncaught exception and reaches the global error handler.
function low() {
  throw new Error('disk full');
}

function mid() {
  // Not caught here — propagates up
  low();
}

function high() {
  try {
    mid();
  } catch (e) {
    console.log('Caught:', e.message); // "disk full"
  }
}

high(); // Error thrown in low(), caught in high()

// Partial handling + rethrow
function process() {
  try {
    low();
  } catch (e) {
    logError(e);     // handle partially (log it)
    throw e;         // let caller handle too
  }
}

// Error wrapping — add context while preserving original
function loadConfig() {
  try {
    readFile('config.json');
  } catch (e) {
    throw new Error('Config load failed', { cause: e });
  }
}
The cause property (ES2022) enables error wrapping — you catch a low-level error, wrap it in a higher-level error with additional context, and rethrow. This preserves the original error chain while making error messages more meaningful at each abstraction layer.

Why it matters: Understanding error propagation determines whether errors are handled at the right layer of abstraction. Low-level errors (network timeout) shouldn't bubble to the UI as raw messages — they should be caught and re-wrapped with user-relevant context at each layer.

Real applications: Repository layer catching DB errors and wrapping in DataAccessError, service layer catching those and wrapping in ServiceError, controller layer catching and returning appropriate HTTP status codes, and maintaining the full causal chain for debugging while presenting clean messages to users.

Common mistakes: Catching and swallowing errors without rethrowing (hides bugs), rethrowing only the new error without the cause (loses original context), not checking if an error is expected before handling (catching all errors hides programmer mistakes), and not logging or tracking errors at any point in a long propagation chain.

Always throw Error objects (not strings or numbers), use specific error types for different failure categories, handle errors as close to the source as possible, never silently swallow errors with empty catch blocks, and set up global handlers as a safety net for anything that slips through.
// Bad: throwing strings
throw 'something failed';

// Good: throw Error objects
throw new Error('something failed');

// Bad: empty catch (swallowing errors)
try { riskyCall(); } catch (e) {}

// Good: log or rethrow
try { riskyCall(); }
catch (e) { console.error(e); throw e; }

// Good: Use specific error types
if (!user) throw new NotFoundError('User');
if (!email.includes('@')) throw new ValidationError('email');

// Good: Error cause for context
try {
  await fetchData();
} catch (e) {
  throw new Error('Data load failed', { cause: e });
}

// Good: Global safety net
window.onerror = (msg) => logService.send(msg);
window.addEventListener('unhandledrejection', (e) => {
  logService.send(e.reason);
});
Additional best practices: use Error.cause (ES2022) to chain errors and preserve context, prefer async/await with try/catch over .catch() chains for readability, always validate data at system boundaries (API inputs, file reads), and use a centralized error reporting service in production to catch issues early.

Why it matters: Error handling is a cross-cutting concern that affects every layer of an application. Clean error handling patterns are the difference between apps that gracefully recover from failures and apps that show blank screens or corrupt data.

Real applications: Centralized API error interceptors in Axios, React error boundaries for UI crash isolation, structured logging with error context (request ID, user ID, timestamp), graceful degradation when non-critical features fail, and circuit breaker patterns for repeatedly-failing services.

Common mistakes: Using empty catch blocks (silent failure is the worst kind), showing raw error messages to users (security risk — may expose internals), not validating at system boundaries (trust but verify all external data), and not distinguishing between recoverable errors (retry, fallback) and fatal errors (crash and report).

The Error.cause property (ES2022) allows you to chain errors by attaching the original error to a new, higher-level error. This preserves the full error chain while adding meaningful context at each layer. Pass it as the second argument to the Error constructor using { cause: originalError }.
// Basic error cause chaining
async function fetchJSON(url) {
  try {
    const response = await fetch(url);
    return await response.json();
  } catch (err) {
    throw new Error('Failed to fetch JSON from ' + url, { cause: err });
  }
}

// Multi-level error chain
async function loadUserProfile(id) {
  try {
    return await fetchJSON('/api/users/' + id);
  } catch (err) {
    throw new Error('Profile load failed for user ' + id, { cause: err });
  }
}

// Inspecting the cause chain
try {
  await loadUserProfile(42);
} catch (err) {
  console.log(err.message);        // "Profile load failed for user 42"
  console.log(err.cause.message);  // "Failed to fetch JSON from /api/users/42"
  console.log(err.cause.cause);    // Original TypeError or network error
}
Error cause creates a linked chain of errors similar to Java's exception chaining. Each level adds context about what operation failed, while the deepest cause reveals the root problem. This is invaluable for debugging complex async workflows where errors pass through multiple layers.

Why it matters: Error.cause was specifically added to fill the gap of structured error chaining in JavaScript. Without it, developers either swallowed root causes or used custom fields inconsistently. Now there's a standard way to preserve the full error context through layers.

Real applications: Chaining DB errors through service and controller layers, preserving original network errors inside HTTP client errors, structured error logging where the full cause chain is serialized, error monitoring services like Sentry displaying causal chains, and libraries that wrap errors to add domain context.

Common mistakes: Not using Error.cause when rethrowing wrapped errors (the original error is lost), accessing cause without checking if it exists (may be undefined), not recursively traversing the cause chain when logging (only logs top-level message), and using cause for non-error values (it should be an Error instance for consistent tooling support).

In Promise chains, errors are handled using .catch() which catches any rejection or thrown error from preceding .then() handlers. A single .catch() at the end of the chain handles errors from all previous steps. You can also recover from errors and continue the chain.
// Single catch at the end
fetch('/api/data')
  .then(res => res.json())
  .then(data => processData(data))
  .then(result => displayResult(result))
  .catch(err => console.error('Something failed:', err));

// Mid-chain recovery
fetch('/api/primary')
  .then(res => res.json())
  .catch(err => {
    console.warn('Primary failed, trying fallback');
    return fetch('/api/fallback').then(r => r.json());
  })
  .then(data => console.log('Got data:', data))
  .catch(err => console.error('Both failed:', err));

// .then() with reject handler vs .catch()
promise.then(onSuccess, onError);  // onError only catches promise rejection
promise.then(onSuccess).catch(onError); // catches both rejection AND onSuccess errors
The key difference between .then(success, error) and .then(success).catch(error) is that the second form also catches errors thrown inside the success handler. Always prefer .catch() at the end of chains for comprehensive error handling.

Why it matters: Promise chains behave differently from synchronous try/catch in subtle ways. Understanding .then(onFulfilled, onRejected) vs .then().catch() prevents missing errors thrown in success handlers, which is a common bug in complex Promise chains.

Real applications: Handling HTTP error codes in fetch chains, recovering from specific error types with .catch(err => fallback()), adding error logging middleware in Promise chains, using .finally() for cleanup in API client methods, and building retry logic by returning a new Promise inside .catch().

Common mistakes: Using .then(success, error) thinking it catches all errors including those from success (it doesn't — use .catch()), not returning from .catch() (the chain continues as resolved, masking the error), chaining .catch() in the middle without rethrowing (subsequent .then() runs as if succeeded), and forgetting that .catch() is .then(undefined, onRejected) — it returns a Promise too.

Operational errors are expected runtime problems like network failures, invalid user input, or file-not-found — these should be handled gracefully. Programmer errors are bugs in the code like calling a function with wrong arguments or accessing properties on null — these should be fixed, not caught and hidden.
// Operational errors — handle gracefully
try {
  const data = await fetch('/api/data');
  if (!data.ok) {
    // Expected failure — show user-friendly message
    showError('Server is temporarily unavailable');
  }
} catch (networkErr) {
  showError('Please check your internet connection');
}

// Programmer errors — fix the code, don't catch
// These indicate bugs:
// - TypeError: Cannot read properties of undefined
// - RangeError: Invalid array length
// - Passing wrong argument types

// Bad: hiding programmer errors
try {
  const result = processData(null); // bug: null should not be passed
} catch (e) {
  // Silently swallowing a bug!
  return defaultValue;
}

// Good: validate at boundaries, let bugs crash
function processData(data) {
  if (!data) throw new TypeError('data is required'); // fail fast
  return data.map(item => item.value);
}
The general rule: handle operational errors (retry, fallback, user message) and let programmer errors crash with clear error messages so they get noticed and fixed. Catching programmer errors silently leads to hidden bugs and unpredictable behavior.

Why it matters: The operational vs programmer error distinction determines recovery strategy. Operational errors should be handled gracefully; programmer errors should crash loudly. Mixing them up leads to either poor UX (crashing on network failures) or hidden bugs (silently swallowing null dereferences).

Real applications: Retrying operational errors (network timeout, rate limiting) with exponential backoff, showing user-friendly messages for validation errors, crashing fast on programmer errors in Node.js (null reference, wrong argument type), centralized error classification in production monitoring, and test strategies that explicitly test operational error recovery.

Common mistakes: Wrapping all code in try/catch including programmer errors (hides bugs that should be fixed), treating all errors as operational (retrying a programmer error is pointless), not logging programmer errors before crashing (lose context for debugging), and not having a clear policy for what constitutes each type in your codebase.

An error boundary is a pattern that wraps a section of code to catch and handle errors without crashing the entire application. While React has built-in error boundaries, you can implement similar patterns in vanilla JavaScript using try/catch wrappers around critical code sections.
// Function wrapper error boundary
function errorBoundary(fn, fallback) {
  return function(...args) {
    try {
      return fn.apply(this, args);
    } catch (error) {
      console.error('Error caught by boundary:', error);
      return typeof fallback === 'function' ? fallback(error) : fallback;
    }
  };
}

// Usage
const safeParseJSON = errorBoundary(JSON.parse, null);
safeParseJSON('invalid');  // null (instead of throwing)
safeParseJSON('{"a":1}');  // { a: 1 }

// Async error boundary
function asyncBoundary(fn, fallback) {
  return async function(...args) {
    try {
      return await fn.apply(this, args);
    } catch (error) {
      console.error('Async error:', error);
      return typeof fallback === 'function' ? fallback(error) : fallback;
    }
  };
}

// Widget isolation
function renderWidget(name, renderFn) {
  try {
    renderFn();
  } catch (e) {
    console.error(name + ' failed:', e);
    document.getElementById(name).textContent = 'Widget unavailable';
  }
}
Error boundaries are especially useful in modular applications where one component's failure should not bring down the entire page. Each independent widget or feature can be wrapped in its own boundary, allowing the rest of the application to continue functioning normally.

Why it matters: Error boundaries implement the bulkhead pattern from distributed systems — isolating failures so a problem in one subsystem doesn't cascade. In large SPAs, this is essential for resilience: a broken analytics widget shouldn't crash the checkout flow.

Real applications: React error boundaries for component tree isolation, web component error boundaries, dashboard widgets each wrapped independently, plugin/extension systems where third-party code can fail safely, and service worker error isolation for offline-capable apps.

Common mistakes: Placing a single error boundary at the root (catches everything but provides no isolation), not providing a meaningful fallback UI (blank areas confuse users), forgetting that React error boundaries only catch render errors (not event handlers or async code — those need try/catch), and not logging boundary-caught errors to a monitoring service.

Optional catch binding (ES2019) allows you to omit the error parameter in a catch block when you do not need to reference the error. This produces cleaner code when you only care that an error occurred, not what the error was.
// Before ES2019 — parameter required even if unused
try {
  JSON.parse(input);
} catch (unusedError) {
  return defaultValue;
}

// ES2019+ — parameter is optional
try {
  JSON.parse(input);
} catch {
  return defaultValue;
}

// Common use cases

// Feature detection
let supportsFeature = false;
try {
  supportsFeature = typeof SharedArrayBuffer !== 'undefined';
} catch {
  // Not available in this environment
}

// Silent fallback when error details don't matter
function tryParseInt(str) {
  try {
    return parseInt(str, 10);
  } catch {
    return 0;
  }
}
Use optional catch binding when the error object is truly not needed — for example, in feature detection, fallback logic, or cleanup code. If you might need to log or rethrow the error, always keep the parameter to preserve access to the error details.

Why it matters: Optional catch binding is a minor ergonomic improvement that was added specifically for feature detection and fallback patterns where checking the error type adds no value. It signals intent: "I know an error might occur and I deliberately don't need to examine it."

Real applications: Feature detection (try { new Worker() } catch { }), providing fallback values for non-critical operations, polyfill detection logic, optional JSON parsing with a fallback default, and cleanup code where any error means "proceed regardless."

Common mistakes: Omitting the error parameter when you actually need to know the error type (now you can't log or rethrow selectively), using optional catch binding as a habit to skip writing error-handling logic (silent failure), and not knowing this syntax was added in ES2019 (older environments may not support it without a transpiler).

AggregateError (ES2021) wraps multiple errors into a single error object. It is primarily thrown by Promise.any() when all promises reject, but you can also create it manually to group related errors from batch operations.
// Promise.any — throws AggregateError when all reject
try {
  const result = await Promise.any([
    fetch('https://primary.api/data'),
    fetch('https://backup.api/data'),
    fetch('https://fallback.api/data')
  ]);
} catch (err) {
  console.log(err instanceof AggregateError); // true
  console.log(err.message);  // "All promises were rejected"
  console.log(err.errors);   // array of individual errors
  err.errors.forEach((e, i) => {
    console.log('Error ' + i + ':', e.message);
  });
}

// Manual AggregateError for batch validation
function validateForm(fields) {
  const errors = [];
  if (!fields.name) errors.push(new Error('Name required'));
  if (!fields.email) errors.push(new Error('Email required'));
  if (!fields.age || fields.age < 0) errors.push(new Error('Invalid age'));

  if (errors.length > 0) {
    throw new AggregateError(errors, 'Form validation failed');
  }
}
AggregateError extends Error and has an errors property containing the array of individual error objects. This is useful for batch operations like form validation, parallel API calls, or any scenario where multiple things can fail independently and you want to report all failures at once.

Why it matters: Before AggregateError, reporting multiple failures required custom error types or arrays of errors passed as metadata. AggregateError standardizes this pattern and is already used natively by Promise.any() to report all rejection reasons.

Real applications: Form validation with multiple field errors returned at once, batch API request error reporting, parallel file processing where multiple files may fail, aggregating errors from multiple independent service calls in Promise.any(), and building custom validators that report all issues rather than stopping at the first.

Common mistakes: Not iterating over error.errors to get individual failures (just logging the AggregateError as a whole misses the details), confusing AggregateError with a general-purpose multi-error container when it's specifically used by Promise.any(), and not knowing that Promise.any() uses AggregateError automatically when ALL promises reject.

Retry logic automatically re-attempts a failed operation a specified number of times with optional delays between attempts. This is essential for handling transient errors like network timeouts, rate limiting, and temporary server unavailability.
// Basic retry with exponential backoff
async function retry(fn, maxRetries = 3, baseDelay = 1000) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;

      const delay = baseDelay * Math.pow(2, attempt - 1);
      console.log('Attempt ' + attempt + ' failed, retrying in ' + delay + 'ms');
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
const data = await retry(() => fetch('/api/data').then(r => r.json()));

// Retry with condition — only retry certain errors
async function retryIf(fn, shouldRetry, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries || !shouldRetry(error)) throw error;
      await new Promise(r => setTimeout(r, 1000 * attempt));
    }
  }
}

// Only retry on network errors, not 4xx
await retryIf(
  () => fetchData(),
  (err) => err.status >= 500 || err.message === 'Failed to fetch'
);
Exponential backoff (doubling the delay each retry) prevents overwhelming a struggling server. Add jitter (random variation) to avoid thundering herd problems where many clients retry simultaneously. Set a reasonable maxRetries limit to prevent infinite loops.

Why it matters: Retry logic with backoff transforms transient failures into eventual success without hammering a failing service. It's a critical pattern for resilient production applications, and a common system design interview topic.

Real applications: Retrying failed API calls to third-party services, database reconnection with backoff in Node.js microservices, message queue consumer retry on processing failure, cloud function retry policies for intermittent dependencies, and client-side form submission retry on network instability.

Common mistakes: Retrying non-retryable errors (4xx client errors should not be retried — only 5xx server errors and network timeouts), not adding jitter (causes thundering herd when many clients retry on the same backoff schedule), retrying indefinitely (set a max and eventually escalate or fail), and not checking whether the operation is idempotent before retrying (POST requests may create duplicate records).

Generators have a throw() method that injects an error into the generator at the point where it last yielded. The generator can catch this error internally with try/catch, or let it propagate up to the caller if not caught.
function* dataProcessor() {
  while (true) {
    try {
      const data = yield 'waiting for data';
      console.log('Processing:', data);
    } catch (error) {
      console.error('Error in generator:', error.message);
      // Can recover and continue, or rethrow
    }
  }
}

const gen = dataProcessor();
gen.next();                              // start generator
gen.next('batch1');                      // "Processing: batch1"
gen.throw(new Error('Invalid data'));    // "Error in generator: Invalid data"
gen.next('batch2');                      // continues: "Processing: batch2"

// return() — force generator to finish
gen.return('done');                      // { value: 'done', done: true }

// Async generator error handling
async function* fetchPages(urls) {
  for (const url of urls) {
    try {
      const res = await fetch(url);
      yield await res.json();
    } catch (err) {
      yield { error: err.message, url };
    }
  }
}
The generator.return(value) method forces the generator to complete, triggering any finally blocks inside it. When using for...of loops with generators, an unhandled error inside the generator will propagate to the loop and terminate iteration.

Why it matters: Generators can receive errors via generator.throw(), which resumes execution with a throw inside the generator at the current yield point. Understanding this enables sophisticated error handling in generator-based async flows like the legacy co library and redux-saga.

Real applications: Error signaling in saga-style generators (redux-saga), propagating cancellation errors via generator.throw(), handling generator cleanup in finally blocks when iteration is aborted, and building custom async iterators that report errors to the consuming for-await-of loop.

Common mistakes: Not wrapping yield expressions in try/catch inside the generator when you want to recover from generator.throw() calls, not knowing that generator.throw() on an unstarted generator throws immediately without entering the generator body, and forgetting that for...of with generators swallows generator.return() but propagates generator.throw().

A global error logging service centralizes error collection by combining window.onerror, unhandledrejection, and a reporting mechanism. This captures both synchronous and asynchronous errors across the entire application and sends them to a backend for monitoring.
class ErrorLogger {
  constructor(endpoint) {
    this.endpoint = endpoint;
    this.queue = [];
    this.init();
  }

  init() {
    // Catch synchronous errors
    window.onerror = (msg, source, line, col, error) => {
      this.log({ type: 'error', msg, source, line, col, stack: error?.stack });
      return true;
    };

    // Catch unhandled promise rejections
    window.addEventListener('unhandledrejection', (event) => {
      this.log({ type: 'rejection', reason: String(event.reason) });
    });

    // Flush queue periodically
    setInterval(() => this.flush(), 5000);
    window.addEventListener('beforeunload', () => this.flush());
  }

  log(errorData) {
    this.queue.push({
      ...errorData,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent
    });
  }

  flush() {
    if (this.queue.length === 0) return;
    const batch = this.queue.splice(0);
    navigator.sendBeacon(this.endpoint, JSON.stringify(batch));
  }
}

const logger = new ErrorLogger('/api/errors');
Use navigator.sendBeacon() for sending error data because it works even during page unload — unlike fetch, it guarantees delivery when the user navigates away. Batch errors to avoid overwhelming the server, and include context like the current URL, timestamp, and user agent to make debugging easier.

Why it matters: A production logging service is essential for visibility into real-world errors. Without it, you're flying blind. Client-side errors happen on devices and browsers you can't reproduce — structured logging is the only way to know what's actually failing.

Real applications: Building a lightweight alternative to Sentry for internal projects, instrumenting a microfrontend architecture with centralized error reporting, collecting JS errors in static site generators, reporting web vitals and errors from embedded widgets, and logging errors with correlation IDs that tie to server-side request logs.

Common mistakes: Making a synchronous logging call in window.onerror (blocks event thread), not using navigator.sendBeacon() for logging during page unload (fetch may be canceled), logging without rate limiting (one bug causes thousands of identical log entries per second), and not including enough context (URL, user session, timestamp) for production debugging.

Structured error handling uses a hierarchy of custom Error subclasses to categorize errors by domain and severity. This enables precise error handling where different types of failures trigger different recovery strategies, rather than treating all errors the same way.
// Base application error
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.isOperational = true;
  }
}

// Specific error types
class NotFoundError extends AppError {
  constructor(resource) {
    super(resource + ' not found', 404);
  }
}

class ValidationError extends AppError {
  constructor(field, detail) {
    super('Validation failed: ' + detail, 400);
    this.field = field;
  }
}

class AuthError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401);
  }
}

// Centralized error handler
function handleError(err) {
  if (err instanceof ValidationError) {
    showFieldError(err.field, err.message);
  } else if (err instanceof NotFoundError) {
    showNotFoundPage();
  } else if (err instanceof AuthError) {
    redirectToLogin();
  } else {
    // Unknown error — log and show generic message
    console.error('Unexpected error:', err);
    showGenericError();
  }
}
The isOperational flag distinguishes expected errors from unexpected bugs — operational errors are handled gracefully while non-operational errors may require a process restart in server environments. Using this.constructor.name for the error name ensures it automatically matches the class name without manual assignment.

Why it matters: A structured error hierarchy with an isOperational flag is the architectural pattern for distinguishing self-healing (operational) from fatal (programmer) errors. It enables automatic error classification at the process level and clean monitoring dashboards.

Real applications: Express.js error middleware routing to different handlers based on error type, process manager restart logic triggered only for non-operational errors, API response formatting based on error class (NotFoundError → 404, AuthError → 401), and testing infrastructure that expects specific error classes from service methods.

Common mistakes: Not extending from a base AppError class (you can't use instanceof checks consistently), using string comparison on error.message for routing (fragile — messages change, classes don't), not setting isOperational (everything defaults to fatal), and creating too many fine-grained error subclasses when a status code + message is sufficient for most use cases.