This topic is one of the core implementation areas covered by Frontend Error Boundary Architecture & Fundamentals, which defines the broader scoping model and lifecycle contract these strategies operate within.

The Problem This Solves #

An async data-fetch inside a React useEffect rejects. The component that initiated the fetch has already unmounted. The rejection propagates into the microtask queue with no active try/catch frame, bypasses every declarative boundary in the tree, and surfaces in the browser console as an uncaught UnhandledPromiseRejection — invisible to your monitoring stack and unrecoverable from the user’s perspective.

This is the failure class error propagation strategies exist to close: asynchronous errors that detach from the synchronous execution context and land outside the reach of framework lifecycle hooks, combined with nested boundary structures that swallow errors silently instead of routing them upward.

Prerequisites #


Architecture Overview #

The diagram below maps the full propagation surface: synchronous render errors travel up the virtual DOM tree and are caught by the nearest active boundary; asynchronous rejections detach from that tree and must be intercepted globally before being re-injected into the same activation path.

Error propagation flow diagram Synchronous render errors bubble up through the component tree and are caught by the nearest error boundary. Asynchronous promise rejections detach from the call stack, pass through window.unhandledrejection, get enriched and re-injected into the boundary activation path, then reach telemetry and fallback rendering. App root Outer boundary Inner boundary Component render() throws sync bubble useEffect / fetch Promise rejects unhandledrejection enrich + classify Boundary activation fallback render + telemetry dispatch TelemetryQueue enqueue + flush synchronous path (left) asynchronous path (right)

Core Implementation: Async Wrapper and Global Rejection Router #

The primary solution is a two-part system: a typed wrapper that enriches async errors at the call site with boundary identity, and a global listener that injects those enriched errors into the same activation path used by synchronous componentDidCatch catches.

// types.ts
export interface EnrichedError extends Error {
  boundaryId: string;
  severity: "critical" | "degraded" | "informational";
  timestamp: number;
  boundaryDepth: number;
  componentPath: string[];
}

// async-error-wrapper.ts
// Wraps any async handler so thrown values arrive as EnrichedErrors
// rather than raw promise rejections with no boundary context.
export function createAsyncBoundary<T extends (...args: unknown[]) => Promise<unknown>>(
  handler: T,
  boundaryId: string,
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>> {
  return async (...args) => {
    try {
      return (await handler(...args)) as Awaited<ReturnType<T>>;
    } catch (err) {
      const base = err instanceof Error ? err : new Error(String(err));
      const enriched = Object.assign(
        new Error(base.message) as EnrichedError,
        {
          name: base.name,
          stack: base.stack,
          boundaryId,
          severity: classifyError(base),
          timestamp: Date.now(),
          boundaryDepth: 0,
          componentPath: [],
        } satisfies Omit<EnrichedError, keyof Error>,
      );
      throw enriched;
    }
  };
}

// classify-error.ts
// Severity tier drives fallback component selection and telemetry routing.
export function classifyError(
  err: Error,
): EnrichedError["severity"] {
  if (/Network|Auth|Unauthorized|403|401/i.test(err.message)) return "critical";
  if (/Render|Hydration|ChunkLoad|UI/i.test(err.message)) return "degraded";
  return "informational";
}

// rejection-router.ts
// Single global listener; call once at application boot.
// The `event.preventDefault()` suppresses the browser console error —
// only do this if you are guaranteed to forward the error to your own
// monitoring stack; otherwise the error disappears silently.
export function registerRejectionRouter(
  activate: (err: EnrichedError) => void,
): () => void {
  const handler = (event: PromiseRejectionEvent): void => {
    event.preventDefault();
    const err =
      event.reason instanceof Error
        ? (event.reason as EnrichedError)
        : Object.assign(new Error(String(event.reason)) as EnrichedError, {
            boundaryId: "global",
            severity: classifyError(new Error(String(event.reason))),
            timestamp: Date.now(),
            boundaryDepth: 0,
            componentPath: [],
          } satisfies Omit<EnrichedError, keyof Error>);
    activate(err);
  };
  window.addEventListener("unhandledrejection", handler);
  return () => window.removeEventListener("unhandledrejection", handler);
}

Architecture Note #

Synchronous render errors travel up the virtual DOM tree via React’s getDerivedStateFromError / componentDidCatch lifecycle or Vue’s errorCaptured hook. These hooks fire in the same call stack as the render, which means the framework can intercept them before the browser’s default error handler fires.

Asynchronous errors bypass this path entirely. A rejected promise that is not awaited inside a boundary-wrapped render function exits the synchronous frame and queues a microtask. By the time that microtask settles, the component tree may have re-rendered or unmounted. The only reliable interception point is window.addEventListener('unhandledrejection'). Registering this handler at boot and routing it through the same activate callback used by componentDidCatch creates a single, unified propagation path regardless of error origin.

Web Workers operate outside the main-thread boundary scope entirely. Worker errors must be forwarded via postMessage with an explicit error payload structure; the worker’s own onerror handler is the insertion point.


Edge Cases and Gotchas #

Failure mode Context Mitigation
Component unmounts before async error resolves React strict-mode double-invocation, route transitions Store the cleanup function returned by registerRejectionRouter and call it on unmount; check a mounted ref before calling activate
Suspense boundary intercepts error before boundary React <Suspense> wrapping async components Place the error boundary outside the Suspense boundary in the tree; Suspense handles loading state, error boundary handles thrown errors
Hydration mismatch errors bypass client boundaries SSR with React 18 hydrateRoot Wrap hydrateRoot in a try/catch and feed failures into the same activate path; server-side errors must be serialised into <script> tags and picked up before hydration begins
Third-party script rejections pollute the queue Ad-tech and analytics loaded async Filter by event.reason?.stack for known third-party origins; use a denylist of source patterns before calling activate
Infinite re-render from re-throw loop Boundary catches, re-throws, catches again Track activation count per boundaryId in a Map; if the same boundary activates more than once per 500 ms, escalate to the parent instead of re-throwing
Web Worker errors silently dropped Worker spawned without onerror handler Register worker.onerror and worker.addEventListener('messageerror') at spawn time; forward via postMessage({ type: 'ERROR', payload })

Advanced Variant: Nested Boundary Re-Throw with Depth Tracking #

How to prevent error boundary swallowing in nested components covers the full taxonomy of swallowing patterns. The implementation below is the core re-throw utility those patterns depend on.

// rethrow-with-depth.ts
// Append boundary depth metadata to the stack so outer boundaries and
// telemetry can reconstruct the propagation path without losing the
// original stack frames.
export function rethrowEnriched(
  err: EnrichedError,
  boundaryId: string,
  currentDepth: number,
  maxDepth: number,
): EnrichedError {
  err.componentPath = [...(err.componentPath ?? []), boundaryId];
  err.boundaryDepth = currentDepth;
  err.stack = `${err.stack ?? ""}\n  [boundary: ${boundaryId} depth: ${currentDepth}]`;
  if (currentDepth < maxDepth) throw err; // bubble to outer boundary
  // At maxDepth, stop bubbling — activate the outermost boundary in place
  return err;
}

// boundary-scope-registry.ts
// Centralises scope configuration so boundary granularity decisions live
// in one place rather than scattered across component files.
export interface BoundaryScope {
  id: string;
  level: "global" | "feature" | "component";
  maxDepth: number;
  telemetryRouting: "all" | "critical-only" | "none";
  recoveryStrategy: "reload" | "fallback" | "retry";
}

const registry = new Map<string, BoundaryScope>();

export function registerBoundaryScope(scope: BoundaryScope): void {
  registry.set(scope.id, scope);
}

export function resolveScope(boundaryId: string): BoundaryScope {
  return (
    registry.get(boundaryId) ?? {
      id: boundaryId,
      level: "component",
      maxDepth: 3,
      telemetryRouting: "all",
      recoveryStrategy: "fallback",
    }
  );
}

// route-error-guard.ts
// Lazy-loaded route components that fail to load must not silently redirect
// to a blank page. This guard intercepts chunk-load failures before navigation
// completes. Adapt the type signature for React Router's loader API or
// Next.js's error.tsx convention as needed.
export async function routeErrorGuard(
  load: () => Promise<unknown>,
  fallbackPath: string,
  navigate: (path: string) => void,
): Promise<void> {
  try {
    await load();
  } catch (err) {
    const enriched = err instanceof Error ? err : new Error(String(err));
    // Chunk-load failures are almost always transient network errors
    enriched.message = `ChunkLoad: ${enriched.message}`;
    navigate(fallbackPath);
  }
}

Testing and CI/CD Validation #

Validate the full propagation path in automated pipelines by injecting synthetic errors at each entry point and asserting the output shape at the telemetry queue.

// propagation.test.ts
import { describe, it, expect, vi, afterEach } from "vitest";
import { registerRejectionRouter } from "./rejection-router";
import { createAsyncBoundary, classifyError } from "./async-error-wrapper";
import { rethrowEnriched } from "./rethrow-with-depth";
import type { EnrichedError } from "./types";

describe("rejection router", () => {
  afterEach(() => vi.restoreAllMocks());

  it("routes unhandledrejection to activate callback", async () => {
    const activate = vi.fn();
    const cleanup = registerRejectionRouter(activate);

    const event = new PromiseRejectionEvent("unhandledrejection", {
      promise: Promise.reject(new Error("Network timeout")),
      reason: new Error("Network timeout"),
      cancelable: true,
    });
    window.dispatchEvent(event);

    await vi.runAllMicrotasksAsync();
    expect(activate).toHaveBeenCalledOnce();
    const err = activate.mock.calls[0][0] as EnrichedError;
    expect(err.severity).toBe("critical");
    cleanup();
  });
});

describe("async boundary wrapper", () => {
  it("preserves the original stack and enriches with boundaryId", async () => {
    const wrapped = createAsyncBoundary(
      async () => { throw new Error("fetch failed"); },
      "user-profile-boundary",
    );
    await expect(wrapped()).rejects.toMatchObject({
      boundaryId: "user-profile-boundary",
      severity: "informational",
    });
  });
});

describe("rethrow utility", () => {
  it("appends depth metadata without losing original message", () => {
    const base = Object.assign(new Error("UI crash") as EnrichedError, {
      boundaryId: "inner",
      severity: "degraded" as const,
      timestamp: Date.now(),
      boundaryDepth: 0,
      componentPath: [],
    });
    expect(() => rethrowEnriched(base, "inner", 1, 3)).toThrow("UI crash");
  });
});

describe("classifyError", () => {
  it.each([
    ["Network timeout", "critical"],
    ["Unauthorized", "critical"],
    ["Hydration mismatch", "degraded"],
    ["ChunkLoad error", "degraded"],
    ["Cannot read properties of undefined", "informational"],
  ] as const)('classifies "%s" as %s', (message, expected) => {
    expect(classifyError(new Error(message))).toBe(expected);
  });
});

For fault-injection in CI, add a Playwright or Cypress step that:

  1. Intercepts fetch responses for a known API route and returns a 500.
  2. Asserts that the boundary fallback renders within 2 seconds.
  3. Checks that POST /api/telemetry received exactly one payload with the expected severity field.

This validates the end-to-end path without depending on real network errors occurring in a flaky test environment.


Telemetry Hooks and Observability Integration #

Attach structured logging directly to the propagation pipeline before boundary activation, not inside the fallback component. The queue below handles network failures during dispatch and keeps the main thread unblocked.

// telemetry-queue.ts
import { classifyError } from "./async-error-wrapper";
import type { EnrichedError } from "./types";

function buildPayload(
  err: EnrichedError,
  sessionId: string,
): Record<string, unknown> {
  return {
    type: err.name,
    message: err.message,
    // Truncate stack to 8 frames to stay inside typical payload limits
    stack: err.stack?.split("\n").slice(0, 8).join("\n"),
    boundaryId: err.boundaryId,
    boundaryDepth: err.boundaryDepth,
    componentPath: err.componentPath,
    severity: classifyError(err),
    sessionId,
    timestamp: err.timestamp,
    // Redact bearer tokens and auth params from the URL
    url: window.location.href.replace(
      /([?&])(token|key|auth|session)=[^&]+/gi,
      "$1$2=***",
    ),
  };
}

export class TelemetryQueue {
  private queue: Array<Record<string, unknown>> = [];
  private flushing = false;
  private readonly endpoint: string;
  private readonly sessionId: string;

  constructor(endpoint: string, sessionId: string) {
    this.endpoint = endpoint;
    this.sessionId = sessionId;
  }

  enqueue(err: EnrichedError): void {
    this.queue.push(buildPayload(err, this.sessionId));
    if (!this.flushing) void this.flush();
  }

  private async flush(): Promise<void> {
    this.flushing = true;
    let backoff = 200;
    while (this.queue.length > 0) {
      const batch = this.queue.splice(0, 10);
      try {
        await fetch(this.endpoint, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(batch),
          // keepalive ensures delivery even on page unload
          keepalive: true,
        });
        backoff = 200;
      } catch {
        // Re-queue on network failure and back off before retrying
        this.queue.unshift(...batch);
        await new Promise((r) => setTimeout(r, backoff));
        backoff = Math.min(backoff * 2, 10_000);
      }
    }
    this.flushing = false;
  }
}

Two failure modes to guard against: PII leakage in raw stack traces (strip user email and token patterns from err.message before dispatch), and telemetry-triggered secondary crashes (never await the queue flush inside a componentDidCatch or equivalent — always fire-and-forget).


Focused Deep-Dives #

Two focused scenarios extend the patterns on this page into specific implementation questions:


Frequently Asked Questions #

How do I prevent error boundaries from swallowing critical async failures?

Register a single unhandledrejection listener at boot that enriches errors with a severity tier and routes them to the same activate callback used by componentDidCatch. Configure inner boundaries with a maxDepth limit so critical-severity errors always bubble to the outermost layer where telemetry is guaranteed to fire.

What is the safest strategy for preserving session state during a crash?

Commit a serialised snapshot to IndexedDB immediately before boundary activation — not after. On recovery, validate the snapshot version stamp and discard it if it predates the current route’s expected schema. The State Reset and Cleanup Protocols pattern covers the full snapshot-and-rollback implementation.

Should telemetry hooks sit inside or outside error boundaries?

Outside — always. Inside the fallback render path, the telemetry call can itself throw, which triggers a recursive boundary activation. Attach the TelemetryQueue.enqueue call in the same synchronous handler that sets the boundary’s error state, before any fallback rendering begins.

How do I test error propagation without breaking CI pipelines?

Dispatch synthetic PromiseRejectionEvent instances in unit tests to validate the rejection router in isolation. For integration-level assertions, use Playwright’s page.route() to inject API failures and assert that both the fallback UI renders and the telemetry endpoint receives the expected payload structure within a timeout.