This page refines the bubbling model from Error Propagation Strategies with the case where the nearest boundary is deliberately the wrong place to stop.

The exact problem #

A comments widget on an article page catches an error and renders “Comments are unavailable”. Reasonable — except the underlying error was SessionExpiredError. The user’s token is invalid, so nothing on the page will work: the like button silently fails, the follow action 401s, and the only visible symptom is a small message about comments.

The nearest boundary is the right default. It is the wrong answer when the failure invalidates assumptions far beyond that subtree. What is needed is a boundary that can decide “this is not mine” and pass it up — without losing the attribution that says the comments widget was where it surfaced.

Zero-to-working escalation #

Start with a typed severity so the decision is data, not a string match.

// severity.ts
export type Severity = 'local' | 'route' | 'session';

export class AppError extends Error {
  readonly severity: Severity;
  readonly code: string;
  constructor(code: string, message: string, severity: Severity, options?: ErrorOptions) {
    super(message, options);
    this.name = 'AppError';
    this.code = code;
    this.severity = severity;
  }
}

export const SessionExpired = () =>
  new AppError('session.expired', 'Session is no longer valid', 'session');
export const ConfigMissing = (key: string) =>
  new AppError('config.missing', `Runtime config missing: ${key}`, 'session');

/** Non-enumerable bookkeeping so it never lands in a JSON payload. */
export function markReported(err: unknown): void {
  if (err && typeof err === 'object') {
    Object.defineProperty(err, '__reported', { value: true, enumerable: false, configurable: true });
  }
}
export function wasReported(err: unknown): boolean {
  return Boolean(err && typeof err === 'object' && '__reported' in err);
}

export function bumpEscalation(err: unknown): number {
  if (!err || typeof err !== 'object') return 99;
  const current = ('__escalations' in err ? Number((err as { __escalations: number }).__escalations) : 0) + 1;
  Object.defineProperty(err, '__escalations', { value: current, enumerable: false, configurable: true });
  return current;
}

The boundary then decides between handling and escalating, and escalates by rethrowing during render:

// ScopedBoundary.tsx
interface State { error: Error | null; escalate: boolean }

export class ScopedBoundary extends React.Component<Props, State> {
  state: State = { error: null, escalate: false };

  static getDerivedStateFromError(error: Error): State {
    const severity = error instanceof AppError ? error.severity : 'local';
    // 'local' stops here. Anything wider belongs to a boundary above us,
    // unless we have already bounced it too many times.
    const escalate = severity !== 'local' && bumpEscalation(error) <= 2;
    return { error, escalate };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo): void {
    if (wasReported(error)) return;         // reported once, at the first catch
    markReported(error);
    reportCrash({
      scope: this.props.scope,              // the ORIGINAL scope, preserved
      severity: error instanceof AppError ? error.severity : 'local',
      message: error.message,
      componentStack: info.componentStack ?? undefined,
    });
  }

  render(): React.ReactNode {
    const { error, escalate } = this.state;
    if (error && escalate) {
      // Rethrowing HERE (render phase) is what React can route upward.
      throw error;
    }
    if (error) return this.props.fallback;
    return this.props.children;
  }
}
Escalation path for a session-severity error A vertical stack: session boundary at the top, route boundary in the middle, widget boundary at the bottom containing the comments widget. A solid arrow labelled session severity travels from the widget boundary past the route boundary to the session boundary. A separate short arrow labelled local severity stops at the widget boundary. session boundary (root) signs out, shows a re-authenticate screen route boundary passes session severity through widget boundary — scope: comments reports once, then decides: handle or rethrow local severity stops right here severity: session rethrown from render, reported only once, scope stays "comments"

Step-by-step #

  1. Classify at the throw site. SessionExpired() carries its own severity, so no boundary has to guess from a message. Untyped errors default to local, which is the safe direction: contained.
  2. Report exactly once, at the first catch. The innermost boundary knows the most useful scope. Marking the error means every boundary above stays quiet.
  3. Decide in getDerivedStateFromError. This runs in the render phase, so the escalation flag is available before the fallback would render.
  4. Rethrow from render. This is the crux: React can only route a throw it sees during rendering. A throw inside componentDidCatch becomes an uncaught error.
  5. Bound the escalation count. Two hops is enough for widget → route → session. The counter turns a mis-configured tree (a boundary that always escalates, above another that always escalates) from an infinite loop into a contained failure.
  6. Keep the original scope. The dashboard should say “comments crashed with a session error”, not “the root boundary crashed”.
Failure Severity Handled by User sees
Comments API returns 500 local Widget boundary “Comments are unavailable”, page otherwise fine
Malformed comment payload crashes render local Widget boundary Same, plus a crash report scoped to comments
Auth token expired or revoked session Root session boundary Re-authentication screen, state preserved where possible
Runtime config missing a required key session Root session boundary Full-page error with a support reference
Route data loader threw route Route boundary Route fallback with retry; navigation intact
Store hydration produced an invalid shape session Root session boundary Reset-and-reload prompt

Avoiding the loops this pattern can create #

Two loops are easy to create and unpleasant to debug.

The escalation loop. Boundary A escalates everything; boundary B above it also escalates everything. React unwinds, re-renders, re-throws — forever. The bumpEscalation counter caps this at two hops regardless of configuration.

The reset loop. The root boundary handles a session error by clearing storage and re-mounting the tree. The re-mounted tree immediately re-reads the same broken config and throws again. The fix is that a session-severity handler must not auto-retry: it presents an action to the user (sign in again, reload) rather than silently retrying. That constraint pairs with the teardown rules in Managing State Reset After Uncaught Promise Rejections.

Escalation and reset loops, and the guards that stop them Left: two boundary boxes with arrows in both directions forming a cycle, annotated with a hop counter guard capped at two. Right: a root handler box and a remounted tree box forming a cycle, annotated with the guard that session severity never auto-retries. escalation loop reset loop boundary B boundary A guard: __escalations counter on the error stop escalating after 2 hops root handler remounted tree guard: session severity never auto-retries it asks the user to sign in or reload both guards live on the error object, so they survive every boundary the error passes through

Verification #

  1. Throw each severity from a leaf. A local error must render the widget fallback; a session error must reach the root screen. Use the fault registry from Testing & Fault Injection for Error Boundaries.
  2. Count crash reports. Exactly one per failure, with scope equal to the innermost boundary — not the root.
  3. Force a mis-configured tree. Make two nested boundaries both escalate unconditionally; the counter must stop the cycle and the failure must land, not hang the tab.
  4. Assert the render-phase rethrow. Temporarily move the rethrow into componentDidCatch and confirm the error escapes to window.onerror instead of the parent — the behaviour that motivates the design.
  5. Check state preservation. After a session escalation, confirm any draft written by Form State Recovery & Multi-Step Wizards is still on disk, so re-authenticating does not cost the user their work.
Severity to handler mapping Three rows. Local severity is handled by the nearest widget boundary, shows an inline fallback, and allows automatic retry. Route severity is handled by the route boundary, shows a route fallback, and allows a user-triggered retry. Session severity is handled by the root boundary, shows a re-authenticate or reload screen, and forbids automatic retry. severity handled by user sees auto-retry local nearest widget boundary inline fallback allowed route route boundary route fallback + retry user-triggered session root session boundary sign-in or reload screen forbidden

Frequently asked questions #

Why does throwing inside componentDidCatch not reach the parent boundary?

componentDidCatch runs in the commit phase, after the error was handled, so a throw there is treated as uncaught and escapes to the global handler. Store the error in state and rethrow during render.

Which errors deserve escalation?

Anything the local UI cannot recover from without misleading the user: an expired session, missing runtime configuration, a corrupted store, a failed feature-flag bootstrap. A widget’s own fetch failing is local.

How do I stop the same error being reported at every level?

Set a non-enumerable flag on the error the first time it is reported and check it before reporting again, keeping the original scope in the payload.