This guide extends the decision-making covered in Error Boundary Architecture Fundamentals, narrowing in on the single question teams get wrong most often: how many boundaries, and where.

The problem: one boundary is too coarse, twenty is too much #

Most teams start with a single boundary wrapped around the app root. It works — until a date-picker in a sidebar throws on a malformed locale string, and the entire application unmounts: the primary content, the navigation, the in-progress form the user hadn’t submitted yet. The fallback is a full-page “Something went wrong” screen with a reload button, and the reload discards everything the user was doing. Support tickets describe this as “the whole site crashed,” even though the actual defect touched one component three levels deep in a rarely-used panel.

The instinctive fix is to wrap more things. A well-meaning refactor adds a boundary around every card, every list item, every modal. Now a single crash is contained — but the page is also covered in disjointed fallback fragments any time something goes wrong, the number of fallback UIs to design and test has multiplied by twenty, and the telemetry dashboard reports twenty different boundary names for what’s often the same underlying regression firing from twenty render sites. Neither extreme is a strategy; both are what happens when granularity is decided ad hoc, one pull request at a time, instead of by an explicit framework.

User-visible consequence of over-coarse boundaries: an unrelated component’s crash discards the user’s unsaved work and forces a full reload. User-visible consequence of over-fine boundaries: a page riddled with small “failed to load” boxes that make the UI look broken even when only one minor widget actually failed, plus a telemetry stream too noisy to triage.

The fix is to choose granularity by three measurable properties — blast radius (how much of the UI a given crash takes down), recovery UX (what the user experiences when a boundary catches), and dev overhead (how much work each additional boundary costs to build, test, and maintain) — rather than by instinct or by mirroring the component tree one-to-one.

There’s also a fourth axis that’s easy to overlook until the app is already in production: telemetry signal. A boundary that catches an error is also the unit your monitoring dashboard groups crashes by. Put the boundary in the wrong place and the dashboard either can’t distinguish “the checkout flow is broken” from “the whole app is broken” (too coarse), or it fragments a single regression into a dozen differently-named events because the same shared dependency crashes inside a dozen separately-wrapped components (too fine). Getting granularity right isn’t just about user-facing recovery — it’s what makes the crash data usable for triage in the first place.

Prerequisites #


Core implementation: a layered boundary strategy #

The pattern below defines one reusable LayeredBoundary primitive, then three thin wrappers — RootBoundary, RouteBoundary, and FeatureBoundary — that each apply it at a different tier with its own fallback and its own reset scope. Every layer reports to the same telemetry sink, tagged with its level, so blast radius is visible in the data without any extra instrumentation per boundary.

import { Component, type ReactNode, type ErrorInfo } from 'react';

// ─── Shared vocabulary across all three tiers ─────────────────────────────
export type BoundaryLevel = 'root' | 'route' | 'feature';

interface LayeredBoundaryProps {
  level: BoundaryLevel;
  /** Identifies the module for telemetry and independent reset scope */
  name: string;
  children: ReactNode;
  fallback: (reset: () => void) => ReactNode;
  onError?: (error: Error, info: ErrorInfo, level: BoundaryLevel, name: string) => void;
}

interface LayeredBoundaryState {
  hasError: boolean;
}

export class LayeredBoundary extends Component<LayeredBoundaryProps, LayeredBoundaryState> {
  state: LayeredBoundaryState = { hasError: false };

  static getDerivedStateFromError(): LayeredBoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    // Every layer reports through the same shape — level + name is what
    // makes blast-radius analysis possible later without extra wiring.
    this.props.onError?.(error, info, this.props.level, this.props.name);
  }

  // Resetting only clears THIS boundary's error state — siblings and
  // ancestors are untouched, which is what keeps the reset scope independent.
  reset = () => this.setState({ hasError: false });

  render() {
    return this.state.hasError ? this.props.fallback(this.reset) : this.props.children;
  }
}

function reportBoundaryCrash(error: Error, info: ErrorInfo, level: BoundaryLevel, name: string) {
  void fetch('/api/telemetry/boundary-crash', {
    method: 'POST',
    body: JSON.stringify({ level, name, message: error.message, stack: info.componentStack }),
    keepalive: true,
  });
}

// ─── Tier 1: root — last resort, wraps the entire application ────────────
export const RootBoundary = ({ children }: { children: ReactNode }) => (
  <LayeredBoundary
    level="root"
    name="app-root"
    onError={reportBoundaryCrash}
    fallback={(reset) => (
      <FullPageFallback onReload={() => { reset(); window.location.reload(); }} />
    )}
  >
    {children}
  </LayeredBoundary>
);

// ─── Tier 2: route — one boundary per top-level route or layout ──────────
export const RouteBoundary = ({
  routeName,
  children,
}: { routeName: string; children: ReactNode }) => (
  <LayeredBoundary
    level="route"
    name={routeName}
    onError={reportBoundaryCrash}
    fallback={(reset) => <RouteFallback routeName={routeName} onRetry={reset} />}
  >
    {children}
  </LayeredBoundary>
);

// ─── Tier 3: feature — wraps one self-contained module, not one component ─
export const FeatureBoundary = ({
  featureName,
  children,
}: { featureName: string; children: ReactNode }) => (
  <LayeredBoundary
    level="feature"
    name={featureName}
    onError={reportBoundaryCrash}
    fallback={(reset) => <InlineFallback featureName={featureName} onRetry={reset} />}
  >
    {children}
  </LayeredBoundary>
);

Architecture note: granularity is a trade-off, not a preference #

Every layer above trades the same three quantities against each other. A boundary placed higher in the tree has a larger blast radius (more UI goes dark when it catches) but lower dev overhead (one fallback, one reset path, one thing to test). A boundary placed lower has a smaller blast radius and better recovery UX (the rest of the page stays interactive) but costs more to build and produces a noisier telemetry stream, because each additional boundary is a distinct event source that has to be triaged.

The rule that keeps this trade-off manageable: wrap logical modules, not components. A logical module is a unit that a product owner would describe as one thing — “the checkout form,” “the activity feed,” “the settings panel” — not an arbitrary render-tree node. Wrapping at the component level (every <Card>, every <ListItem>) multiplies boundary count without multiplying meaningful isolation, because most individual components in a tree are never independently the root cause of a crash; the module they belong to is. Each boundary must also own an independent reset scope — calling reset() on a feature boundary must not require, or accidentally trigger, a reset of its parent route boundary. The LayeredBoundary class above enforces this by keeping hasError local to each instance; resetting one instance never touches another’s state.

This is also why the three tiers are expressed as three thin wrappers around one primitive rather than three unrelated implementations. Sharing LayeredBoundary guarantees that every tier reports through the same onError shape, so blast-radius analysis downstream doesn’t need special-casing per level — the only thing that changes between RootBoundary, RouteBoundary, and FeatureBoundary is which fallback component renders and what name gets attached for telemetry. When a new tier turns out to be necessary — a “section” tier between route and feature, say, for a route with several large independent panels — it’s a fourth thin wrapper around the same primitive, not a parallel implementation to keep in sync.

A related failure mode worth naming explicitly: granularity decided purely by org chart. It’s tempting to give each team that owns a feature its own boundary, on the theory that ownership maps to isolation. That works when team boundaries and logical-module boundaries coincide, but they frequently don’t — a “growth” team’s banner component might render inside the same logical module as a “core” team’s primary content, sharing layout and sometimes state. Wrapping by team ownership in that case either duplicates a boundary around a module that’s already wrapped by its parent, or misses the actual seam where an independent reset would help. Granularity should track the module graph, not the reporting structure.


Edge cases and gotchas: comparing the three granularity levels #

The matrix below is the reference most teams end up pinning to a wiki page after their first granularity argument, because it turns a subjective “should this be its own boundary” debate into a lookup against four concrete axes. Read it as a spectrum, not three isolated columns — a module that scores “high blast radius, poor recovery UX” under the route tier is a candidate for splitting into its own feature boundary; a module that already sits at the feature tier but shows negligible crash telemetry over several months is a candidate for merging back up, trading a little isolation for less maintenance surface.

Boundary granularity comparison matrix A four-row, three-column matrix comparing Root, Route, and Feature boundary levels across blast radius, recovery UX, dev overhead, and telemetry signal, showing that finer granularity trades more dev overhead and noisier telemetry for smaller blast radius and better recovery UX. Root boundary Route boundary Feature boundary Blast radius Entire app unmounts One route/layout goes blank Single module contained Recovery UX Full reload, work is lost Route-scoped reset, rest of app intact Inline retry, no navigation loss Dev overhead Minimal — one boundary total Moderate — one per route High — fallback per module Telemetry signal Coarse — crash = whole app Useful — pinpoints route Precise — pinpoints module
Failure mode Symptom Mitigation
Boundary wraps a component, not a module Related widgets crash independently even though they share one feature’s data Re-scope the boundary to the module boundary, not the render-tree node
Reset cascades upward accidentally Resetting a feature boundary also clears its parent route’s error state Keep hasError state local per instance; never lift reset into a shared store
Route boundary wraps unrelated widgets One widget’s crash blanks siblings that share only a layout, not data Split into feature boundaries within the route once telemetry shows one widget dominates crashes
No boundary between root and features A crash three components deep in a rarely-touched panel takes down the whole route Add a route-tier boundary as the middle layer before jumping straight to per-feature granularity
Telemetry keyed only by error message Same underlying bug reported once per boundary that catches it, flooding dashboards Key telemetry by level + name + fingerprint so the module of origin is distinguishable from duplicate reports
Over-granular boundaries on cheap components Twenty fallback UIs to design and test for components that never independently crash Apply granularity only at logical-module seams; measure crash rate before adding a new boundary

Advanced variant: a decision function for boundary placement #

Rather than deciding granularity by feel on each pull request, score each module against measurable inputs — criticality, observed crash rate, and whether it shares mutable state with siblings — and let the score recommend a level.

export type BoundaryLevel = 'root' | 'route' | 'feature';

export interface ModuleContext {
  /** Business impact if this module is unavailable */
  criticality: 'low' | 'medium' | 'high';
  /** Crashes per 1,000 sessions, trailing 30 days, from boundary telemetry */
  crashRate: number;
  /** Other modules this one shares mutable state/context with */
  sharesStateWith: string[];
}

export function chooseBoundaryLevel(context: ModuleContext): BoundaryLevel {
  // High-criticality modules with an elevated crash rate justify the dev
  // overhead of a dedicated boundary — independent recovery matters more
  // here than avoiding one extra fallback UI.
  if (context.criticality === 'high' && context.crashRate > 2) {
    return 'feature';
  }

  // Shared mutable state means an isolated reset would leave siblings with
  // an inconsistent view of that state. Escalate to the nearest boundary
  // that owns the whole shared-state graph instead of isolating one piece.
  if (context.sharesStateWith.length > 0) {
    return 'route';
  }

  // Low criticality and a crash rate low enough to be noise doesn't justify
  // a dedicated fallback; let it fall through to its parent route boundary.
  if (context.criticality === 'low' && context.crashRate < 0.5) {
    return 'route';
  }

  return 'feature';
}

Note what the function deliberately does not do: it never returns 'root'. The root tier is a fixed, singular boundary that exists once per application regardless of any module’s score — it’s the backstop for errors thrown outside any route (a top-level provider, a router itself failing to match) rather than a placement a scoring function should ever recommand for a feature. Keeping 'root' out of the function’s return type is a small but deliberate guardrail against a scoring bug quietly wrapping the whole app in a boundary meant for one module.

Treat the thresholds (crashRate > 2, crashRate < 0.5) as starting points tuned to session volume, not universal constants — a low-traffic internal tool and a high-traffic consumer app will calibrate differently, and the right move is to review them against the telemetry the boundaries themselves produce, not to guess once and leave them fixed. Applied across an app tree, the three tiers compose into a hybrid layout — most modules fall through to the route boundary, and only the modules chooseBoundaryLevel flags get their own feature boundary:

function DashboardRoute() {
  return (
    <RouteBoundary routeName="dashboard">
      {/* Score: criticality=high, crashRate=3.1 → dedicated feature boundary */}
      <FeatureBoundary featureName="billing-widget">
        <BillingWidget />
      </FeatureBoundary>

      {/* Score: sharesStateWith=['dashboard'] → falls through to RouteBoundary */}
      <ActivityFeed />
      <NotificationBell />
    </RouteBoundary>
  );
}

BillingWidget gets isolation because a crash there has high business impact and a measured crash rate above the threshold; ActivityFeed and NotificationBell share the dashboard’s context, so isolating one would leave the other with stale data on reset — they stay under the shared route boundary instead.


Testing and CI/CD validation #

Fault injection must run once per tier, asserting that only the intended subtree renders its fallback and nothing outside it re-renders or unmounts.

import { render, screen } from '@testing-library/react';
import { RootBoundary, RouteBoundary, FeatureBoundary } from './boundaries';

// A component that throws on demand, used to simulate a crash at any tier
const Bomb = () => {
  throw new Error('Simulated crash');
};

describe('boundary granularity — blast radius', () => {
  it('feature crash leaves sibling features mounted', () => {
    render(
      <RouteBoundary routeName="dashboard">
        <FeatureBoundary featureName="billing-widget">
          <Bomb />
        </FeatureBoundary>
        <FeatureBoundary featureName="activity-feed">
          <div data-testid="activity-feed">Activity feed content</div>
        </FeatureBoundary>
      </RouteBoundary>
    );

    // The crashing feature shows its inline fallback...
    expect(screen.getByText(/billing-widget/i)).toBeInTheDocument();
    // ...but the sibling feature is untouched — blast radius stayed local
    expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
  });

  it('route crash does not reach the root boundary', () => {
    render(
      <RootBoundary>
        <RouteBoundary routeName="dashboard">
          <Bomb />
        </RouteBoundary>
        <nav data-testid="app-nav">Navigation</nav>
      </RootBoundary>
    );

    // The route's fallback renders, but navigation outside it survives —
    // proving the crash didn't escalate past its intended tier
    expect(screen.getByTestId('app-nav')).toBeInTheDocument();
  });

  it('root-tier crash is the only case that takes down navigation', () => {
    const ThrowsDuringRender = () => {
      throw new Error('Uncontained crash above any route boundary');
    };
    render(
      <RootBoundary>
        <ThrowsDuringRender />
        <nav data-testid="app-nav">Navigation</nav>
      </RootBoundary>
    );

    // Only the root-tier test should ever assert navigation is gone —
    // this is the fixture that snapshots the largest acceptable blast radius
    expect(screen.queryByTestId('app-nav')).not.toBeInTheDocument();
  });
});

Run these three fixtures as a matrix in CI on every change to the boundary components themselves — not on every feature PR — so a refactor that accidentally lets a feature-tier crash escalate to the route or root tier fails the build immediately, before it reaches telemetry in production. The assertion that matters in each fixture isn’t just “a fallback rendered” — any single boundary passes that check trivially — it’s that everything outside the intended subtree is unaffected. That’s the actual blast-radius contract this whole strategy exists to enforce, and it’s the one property that’s easy to silently break during a refactor (a key prop hoisted one level too high, a reset() call wired to the wrong instance) without any single unit test for the changed component itself catching it.

It’s worth snapshotting the fixture list itself alongside the assertions — a lightweight table of { level, name, expectedSurvivors } per fixture — and reviewing it whenever a new feature boundary is added. If a new feature boundary’s fixture doesn’t show up in that list, the boundary exists in the tree but isn’t verified, which is functionally the same as not having tested the granularity decision at all.

For the narrower question this framework decides between most often, see When to Use One Root Boundary vs Per-Route Boundaries, which works through the two-tier case in detail — including when a route tier alone, without any feature-level boundaries, is the right stopping point.

The mechanics of how a caught error decides whether to render locally or re-throw to a parent are covered in Error Propagation Strategies; this guide assumes that decision is already made and focuses purely on where boundaries sit. For isolating specific high-risk components — third-party widgets, embeds, anything you don’t control the failure modes of — Component Isolation Techniques covers sandboxing patterns that pair naturally with a feature-tier boundary. Teams building single-page apps specifically should also read Best Practices for Global vs Local Error Boundaries in SPAs, which addresses the client-side routing transitions that complicate reset scope.


Frequently asked questions #

Is one root error boundary ever enough for a production app? Only for very small apps or early-stage products where engineering time is the scarcest resource and a full-page fallback is an acceptable worst case. Once the app has more than a handful of independent features, a single crash taking down the whole UI becomes a support-ticket generator, not just a bad look.

How many boundaries is too many? When the number of boundaries approaches the number of components rather than the number of logical modules. Each boundary is a maintenance surface — a fallback to design, a reset path to test, a telemetry event to monitor. Wrapping every button and list item produces overhead without meaningfully improving recovery UX, because most components in that tier are never the sole cause of a crash.

Should granularity match the route structure or the component structure? Neither exclusively — it should match logical modules, which usually align with routes at the top tier and with features within a route. A route boundary that wraps three unrelated widgets on a dashboard still lets any one widget’s crash blank the other two, which is a component-structure mismatch even though it matches the route structure.

Does adding more boundaries hurt performance? Error boundaries themselves add negligible runtime cost — they’re a class component with two lifecycle methods and no render-path overhead when no error is thrown. The cost is entirely in development and maintenance, not in the browser.

How do I revisit granularity decisions as the app grows? Feed crash-rate telemetry from each boundary back into the chooseBoundaryLevel scoring periodically. A feature that never crashes is a candidate for merging back into its parent route boundary; a route boundary whose telemetry shows one recurring module dominating its crash count is a candidate for splitting out a feature boundary. Treat this as a recurring review, not a one-time architecture decision — the modules that most need their own boundary today are rarely the same ones that needed it when the app launched.

What’s the right default when a module’s classification is genuinely unclear? Default to the route tier. A route boundary is cheaper to build and maintain than a feature boundary, and it’s far easier to split a route-scoped module out into its own feature boundary later — once telemetry shows it actually warrants the isolation — than to have over-built dozens of feature boundaries up front and later prove most of them were unnecessary. Treat chooseBoundaryLevel’s 'route' fallthrough branches as the safe default, and 'feature' as something you promote a module into once the data supports it.