React error boundary not catching async errors? Here's why

Error boundaries only catch render errors. Three fixes for async and event-handler errors: showBoundary, React 19's onUncaughtError, and global window handlers.

Catch Team··8 min read

React error boundaries only catch errors thrown while React is rendering: in the render phase, in lifecycle methods, and in constructors of the tree below them. They do not catch errors thrown in event handlers, in setTimeout or promise callbacks, in async functions, during server-side rendering, or inside the boundary component itself. If your boundary isn't tripping, the error is almost certainly coming from one of those places. The fixes, in increasing order of bluntness: route the error into the boundary yourself (showBoundary or the re-throw trick), hook React 19's root-level error callbacks for reporting, and keep a global window safety net for everything that slips past both.

A 30-second error boundary recap

An error boundary is a class component that implements static getDerivedStateFromError (return new state so the fallback renders) and/or componentDidCatch (side effects, like logging). There is still no hook equivalent — the React docs say so plainly and point to react-error-boundary as the way to avoid writing the class yourself.

The smallest useful boundary:

import { Component } from "react";

class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    console.error(error, info.componentStack);
  }

  render() {
    if (this.state.error) {
      return <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

Wrap a subtree with it, and any error thrown during rendering of that subtree swaps in the fallback instead of unmounting your whole app. So far so good. The trouble starts when the error isn't thrown during rendering.

Why doesn't my error boundary catch async errors?

Because by the time your async code throws, React is no longer on the call stack. A boundary works roughly like a try/catch around React's own render work: it can only catch what's thrown while React is actively rendering the tree below it. An onClick handler, a setTimeout callback, or the continuation after an await all run later, straight from the event loop. The exception propagates to the runtime — not to React — so the boundary never hears about it.

Here's the full map, per the official list of what boundaries don't catch:

Error thrown inCaught by a boundary?
Render / returned JSXYes
Constructors and lifecycle methodsYes
A useEffect body (synchronous throw)Yes — effects run inside React's commit work
Event handlers (onClick, onChange, …)No
setTimeout / setInterval / requestAnimationFrame callbacksNo
Promises, async/await, fetchNo
Server-side renderingNo
The error boundary component itselfNo
A function passed to startTransition from useTransition (React 19)Yes

And the classic failing case — an async event handler, two misses stacked on top of each other:

function BuyButton() {
  async function handleClick() {
    const res = await fetch("/api/checkout", { method: "POST" });
    if (!res.ok) {
      throw new Error(`Checkout failed: ${res.status}`);
    }
  }

  return <button onClick={handleClick}>Buy</button>;
}

Wrap BuyButton in any error boundary you like; when the API is down, the fallback never renders. The throw inside an async function becomes a rejected promise, React never sees it, and all you get is an Unhandled promise rejection line in the console — and a button that silently does nothing. In production, with no error tracking, you get nothing at all.

Fix 1: route async errors into the boundary

The direct fix: catch the error yourself, then make React throw it during render — the one place a boundary is listening.

The re-throw-into-render trick

Store the caught error in state. On the re-render, throw it. Now it's a render-phase error, and the nearest boundary catches it:

import { useState } from "react";

function BuyButton() {
  const [error, setError] = useState(null);
  if (error) {
    throw error; // thrown during render — the boundary catches this
  }

  async function handleClick() {
    try {
      const res = await fetch("/api/checkout", { method: "POST" });
      if (!res.ok) {
        throw new Error(`Checkout failed: ${res.status}`);
      }
    } catch (err) {
      setError(err);
    }
  }

  return <button onClick={handleClick}>Buy</button>;
}

This works with any boundary, your own class included. It's also exactly what react-error-boundary packages up for you.

showBoundary from react-error-boundary

react-error-boundary (v6 as of April 2026) ships a useErrorBoundary hook that returns showBoundary(error) — call it with any thrown value and the nearest ErrorBoundary renders its fallback, exactly as if the error had been thrown in render:

import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";

function BuyButton() {
  const { showBoundary } = useErrorBoundary();

  async function handleClick() {
    try {
      const res = await fetch("/api/checkout", { method: "POST" });
      if (!res.ok) {
        throw new Error(`Checkout failed: ${res.status}`);
      }
    } catch (err) {
      showBoundary(err);
    }
  }

  return <button onClick={handleClick}>Buy</button>;
}

export default function Checkout() {
  return (
    <ErrorBoundary fallback={<p>Checkout is unavailable. Try again shortly.</p>}>
      <BuyButton />
    </ErrorBoundary>
  );
}

One rule: call useErrorBoundary from inside an ErrorBoundary subtree. The hook also returns resetBoundary(), handy for retry buttons in your fallback.

React 19: throw inside startTransition

React 19 added one genuine exception to the async rule: errors thrown from a function passed to startTransition (from useTransition) are caught by the nearest boundary — including async functions:

import { useTransition } from "react";

function BuyButton() {
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(async () => {
      const res = await fetch("/api/checkout", { method: "POST" });
      if (!res.ok) {
        throw new Error(`Checkout failed: ${res.status}`);
      }
    });
  }

  return (
    <button disabled={isPending} onClick={handleClick}>
      {isPending ? "Processing…" : "Buy"}
    </button>
  );
}

If you're on React 19 and the work fits a transition (you get isPending for free), this is the tidiest version: no manual catch, no extra state.

What are onCaughtError and onUncaughtError in React 19?

They're root-level error callbacks. React 19's createRoot (and hydrateRoot) accept three options, each called with (error, errorInfo) where errorInfo carries the componentStack:

  • onCaughtError — an error boundary somewhere in the tree caught an error.
  • onUncaughtErrorno boundary caught a render-phase error.
  • onRecoverableError — React recovered automatically (hydration mismatches are the usual case); the original cause may be on error.cause.
import { createRoot } from "react-dom/client";
import App from "./App";

function logToTracking(kind, error, errorInfo) {
  // swap in your error-tracking SDK call
  console.error(`[${kind}]`, error, errorInfo.componentStack);
}

const root = createRoot(document.getElementById("root"), {
  onCaughtError(error, errorInfo) {
    logToTracking("caught", error, errorInfo);
  },
  onUncaughtError(error, errorInfo) {
    logToTracking("uncaught", error, errorInfo);
  },
  onRecoverableError(error, errorInfo) {
    logToTracking("recoverable", error, errorInfo);
  },
});

root.render(<App />);

Two things make these worth knowing.

First, they're the right place to report boundary-caught errors. Before React 19, React could log a caught error three times; React 19 changed the defaults: caught errors go to console.error once, uncaught errors go to window.reportError, and React no longer re-throws render errors. onCaughtError gives you one hook where every boundary catch in the app lands, component stack attached — no need to bolt componentDidCatch logging onto each boundary.

Second — and this is the part most write-ups miss — these callbacks do not catch async or event-handler errors either. They cover the same territory boundaries do (work that happens inside React's render and commit), only at the root, with reporting hooks. onUncaughtError fires for a render crash with no boundary above it; it stays silent when your onClick rejects. They close the observability gap for render errors. For the async gap, you still need Fix 1 — or Fix 3.

React global error handling: the window safety net

Fix 1 only helps with errors you remembered to route. The catch-all is two listeners on windowerror for synchronous throws and unhandledrejection for promise rejections nobody awaited:

function send(payload) {
  navigator.sendBeacon("/errors", JSON.stringify(payload));
}

window.addEventListener("error", (event) => {
  send({
    type: "error",
    message: event.message,
    stack: event.error?.stack,
    source: `${event.filename}:${event.lineno}:${event.colno}`,
  });
});

window.addEventListener("unhandledrejection", (event) => {
  send({
    type: "unhandledrejection",
    message: String(event.reason),
    stack: event.reason?.stack,
  });
});

That async onClick from the failing example? It surfaces here, as an unhandledrejection. So do errors in third-party scripts, in code that runs before React mounts, and in every callback you didn't wrap. The two listeners overlap less than you'd think — we compared them in detail in window.onerror vs addEventListener('error'), including the cross-origin "Script error." gotcha.

A React 19 nuance that makes this layer more useful, not less: React now reports uncaught render errors via window.reportError, which fires a regular error event, emulating an uncaught exception. So a global error listener still sees render crashes in React 19, just as it did in React 18 when they were re-thrown. One listener, every era of React.

What none of this covers: the server. window doesn't exist during SSR, and boundaries don't run there either — SSR is on the don't-catch list. If you're on Next.js, that's a job for instrumentation.ts, which we covered in Next.js server error tracking with onRequestError.

Which layers do you actually need?

All of them — they do different jobs:

LayerCatchesWhat it buys you
Error boundaryRender-phase errors below itFallback UI instead of a blank page
showBoundary / re-throwAsync + handler errors you explicitly routeFallback UI for async failures too
React 19 root optionsRender-phase errors: caught, uncaught, recoverableOne reporting hook, component stacks included
window listenersEverything else, anywhere on the pageThe telemetry layer of last resort

Boundaries are a UX tool: they decide what the user sees when rendering fails. Global handlers are a telemetry tool: they make sure you see everything, whether or not a boundary did. Production apps want both — a boundary without reporting hides errors from you; reporting without boundaries shows users a white screen.

If you'd rather not maintain that wiring yourself, it's what @catch.dev/react does: global handlers plus boundary reporting in one dependency-free package, with errors grouped into issues so the same rejection firing 4,000 times is one row in your dashboard, not 4,000. npm install @catch.dev/react and sign up free.

The rule worth memorizing: boundaries catch render errors; everything async is yours to route or globally trap. Stop expecting a boundary to be a try/catch for the whole app and the three fixes compose cleanly.

Catch Team

Building catch.dev — the tiny SDK for in-app feedback and crash reports.

// More from the blog