← back to section

React catches exceptions in the component tree via error boundaries — this is covered in detail in the article "Error Handling: error boundary and Suspense". But there is a whole layer of errors that React never sees: asynchronous code outside components, resource loading failures, unhandled promise rejections. These are handled at the platform level — JavaScript and the browser.

try/catch and its limits

try/catch intercepts synchronous exceptions — those thrown directly in the current call stack:

try {
  const data = JSON.parse(rawString);
} catch (err) {
  console.error('Failed to parse JSON:', err.message);
}

The problem: try/catch does not catch errors in asynchronous code triggered via setTimeout, setInterval, or event callbacks.

try {
  setTimeout(() => {
    throw new Error('this error will not be caught');
  }, 0);
} catch (err) {
  // we will never get here
}

By the time the callback executes, the try/catch stack has already unwound. The error goes to the global handler.

Errors in promises

With Promise the situation is similar: a rejected promise without .catch or without await inside a try/catch becomes "unhandled" — the browser fires an unhandledrejection event.

// correct: explicit .catch
fetch('/api/users')
  .then(res => res.json())
  .catch(err => console.error('Load error:', err));

// correct: async/await + try/catch
async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  } catch (err) {
    console.error('Error:', err);
    throw err; // rethrow, do not swallow
  }
}

async/await makes promise error handling identical to synchronous code — try/catch works as expected. This is the main reason async/await replaced .then chains. For more on why this works, see "Asynchronous code and event loop".

Global browser handlers

When an error is not caught locally, the browser fires events on window. This is the entry point for monitoring systems.

window.onerror intercepts unhandled synchronous exceptions and errors in callbacks:

window.onerror = function(message, source, lineno, colno, error) {
  sendToMonitoring({ message, source, lineno, colno, stack: error?.stack });
  return true; // suppress console output
};

unhandledrejection intercepts unhandled promise rejections:

window.addEventListener('unhandledrejection', (event) => {
  event.preventDefault(); // suppress console output
  sendToMonitoring({ reason: event.reason });
});

Together these two handlers cover most error "leaks". They are registered at the application entry point — before React mounts.

Resource loading errors

Image, script, and stylesheet loading failures are a separate case. They do not bubble up as normal events, so the capture phase is required:

window.addEventListener('error', (event) => {
  const target = event.target;
  if (target instanceof HTMLImageElement || target instanceof HTMLScriptElement) {
    sendToMonitoring({ type: 'resource', url: target.src || target.href });
  }
}, true); // true = capture phase

Without the third argument true this handler will not receive resource loading events: they do not bubble up the DOM tree.

Custom error classes

The built-in Error carries only a message and a stack. To distinguish errors by type it is useful to create custom classes. Since ES2022 the cause property is convenient — it preserves the original error:

class ApiError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    options?: ErrorOptions
  ) {
    super(message, options);
    this.name = 'ApiError';
  }
}

// usage
try {
  const data = await loadData();
} catch (err) {
  throw new ApiError('Failed to load data', 500, { cause: err });
}

cause preserves the original error — the stack is not lost, and the full chain of causes is visible when logging.

Check the error type with instanceof:

if (err instanceof ApiError && err.statusCode === 401) {
  redirectToLogin();
}

Production monitoring

Handling an error locally is only half the job. If a user in production sees a failure, the developer must know about it. For this, errors are sent to a monitoring system (Sentry, Rollbar, or a custom endpoint).

The key problem in production is minification. After a build the error stack looks like this:

TypeError: Cannot read properties of undefined
  at e (app.a8f3c2.js:1:4821)

app.a8f3c2.js:1:4821 tells you nothing. Source maps are files generated by the bundler during the build that link minified code to the original. The monitoring service loads them and restores a readable stack:

TypeError: Cannot read properties of undefined
  at loadUserProfile (src/features/profile/ProfilePage.tsx:42:18)

Source maps should not be published publicly — they expose the source code. Typically they are uploaded directly to the monitoring system via CI and removed from public assets.

What not to do

Swallowing errors. An empty catch is one of the most costly defects: the error disappears, the application behaves incorrectly, and the cause is unavailable. Logging without rethrowing is nearly the same: the calling code does not know about the error.

// bad: the error vanished without a trace
try { await saveData(); } catch (e) {}

// good: log and rethrow
try {
  await saveData();
} catch (e) {
  logger.error('Failed to save data', e);
  throw e;
}

Relying solely on console.error. In production nobody watches the user's console. All non-trivial errors must reach the monitoring system.

Not distinguishing errors by type. If all errors are handled the same way it becomes impossible to tell "network is down" from "code bug" — yet these require fundamentally different responses.

In short

  • try/catch intercepts only synchronous exceptions and code with await; setTimeout callbacks are not caught.
  • In promises: .catch for chains; try/catch around await for async/await.
  • window.onerror + unhandledrejection — a global net at the application entry point, covering what was not caught locally.
  • Resource loading errors (<img>, <script>) arrive in addEventListener('error', ..., true) — the capture phase is mandatory.
  • Custom classes (extends Error, cause) provide type and context without losing the stack.
  • In production, without source maps the stack is unreadable — upload them to monitoring, do not publish them.
  • An empty catch is worse than no catch at all: the error disappears and the source becomes impossible to find.
  • Error handling: error boundary and Suspense — how React intercepts render exceptions.
  • Asynchronous code and event loop — why try/catch does not catch callbacks and how the event loop works.
  • Frontend testing: Vitest and Testing Library — how to test error paths in components.