This page is part of the Fallback UI Rendering Patterns cluster, which covers how error boundaries surface failure states without degrading perceived performance.

The exact failure scenario #

A dashboard widget powered by a third-party data feed throws during render. React’s error boundary catches it and swaps the widget subtree with a <SkeletonCard> fallback. The outer grid does not change, the sidebar does not move — but the browser still records a Cumulative Layout Shift score of 0.14, killing the page’s Core Web Vital. The cause: the fallback’s intrinsic height differs from the crashed widget’s rendered height, and no containment policy tells the layout engine to ignore that difference.

The fix is a two-part contract: lock dimensions before the error propagates and constrain geometry so the change never escapes the boundary subtree.

Zero-to-working implementation #

The snippet below is a complete, self-contained solution for React 18+. It works for class-based and functional boundary wrappers.

// useDimensionLock.ts — capture pre-crash geometry and hold it through fallback mount
import { useLayoutEffect, useRef, useState } from 'react';

interface LockedSize { width: number; height: number }

/**
 * Returns a ref to attach to the boundary wrapper and the last-known locked size.
 * Call captureSize() from the error boundary's getDerivedStateFromError path (via a
 * callback prop) or, for hooks-based boundaries, in the layout effect before error state flips.
 */
export function useDimensionLock() {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const [lockedSize, setLockedSize] = useState<LockedSize | null>(null);

  // Runs synchronously after paint — guarantees a real pixel measurement
  useLayoutEffect(() => {
    const el = wrapperRef.current;
    if (!el) return;

    // Snapshot geometry before any error can alter it
    const { width, height } = el.getBoundingClientRect();
    setLockedSize({ width, height });

    // Re-lock on resize so stale dimensions don't persist across viewport changes
    const observer = new ResizeObserver(([entry]) => {
      if (!entry) return;
      const { inlineSize: w, blockSize: h } = entry.contentBoxSize[0];
      setLockedSize({ width: w, height: h });
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return { wrapperRef, lockedSize };
}

// ContainedBoundary.tsx — error boundary wrapper that enforces the no-shift contract
import React, { Component, ReactNode } from 'react';
import { useDimensionLock } from './useDimensionLock';

interface Props {
  fallback: ReactNode;
  children: ReactNode;
}

interface State { hasError: boolean }

// Inner functional component owns the dimension lock; class boundary owns error state
function BoundaryShell({ fallback, children }: Props) {
  const { wrapperRef, lockedSize } = useDimensionLock();
  const [hasError, setHasError] = React.useState(false);

  const containerStyle: React.CSSProperties = {
    contain: 'layout size paint',
    isolation: 'isolate',
    // Locked values prevent outer layout recalculation when fallback mounts
    ...(lockedSize
      ? { width: lockedSize.width, height: lockedSize.height, overflow: 'auto' }
      : { minHeight: '4rem' }),
  };

  return (
    <div ref={wrapperRef} style={containerStyle} data-boundary-shell="true">
      {hasError ? fallback : (
        <ErrorTrigger onError={() => setHasError(true)}>
          {children}
        </ErrorTrigger>
      )}
    </div>
  );
}

// Thin class wrapper — only class components can implement componentDidCatch
class ErrorTrigger extends Component<{
  children: ReactNode;
  onError: () => void;
}, { caught: boolean }> {
  state = { caught: false };

  static getDerivedStateFromError() { return { caught: true }; }

  componentDidCatch(_: Error, info: React.ErrorInfo) {
    // Trigger dimension lock swap in the parent shell before next paint
    this.props.onError();
    console.debug('[ContainedBoundary] error caught', info.componentStack);
  }

  render() {
    return this.state.caught ? null : this.props.children;
  }
}

export { BoundaryShell as ContainedBoundary };

Step-by-step explanation #

  1. useDimensionLock captures geometry synchronously. useLayoutEffect fires after DOM mutation but before the browser paints. getBoundingClientRect() returns the rendered pixel values, not the CSS declarations, so it is accurate even when the parent uses flexbox or grid fractional sizing.

  2. ResizeObserver keeps the lock fresh. Without this, a viewport resize after mount would leave stale dimensions. contentBoxSize[0] is used instead of getBoundingClientRect() inside the observer callback because it fires before the next frame’s paint, avoiding a forced reflow.

  3. contain: layout size paint is the critical declaration. layout prevents the subtree from affecting siblings; size tells the engine the element’s dimensions are self-contained; paint clips overflow without triggering a stacking context on ancestors. All three together mean swapping children never generates a layout-shift entry.

  4. isolation: isolate prevents stacking-context bleed. Without it, z-index or filter changes inside the fallback can repaint ancestor layers, which some browsers record as paint cost that indirectly correlates with shift scoring.

  5. overflow: auto on the locked container. If the fallback is taller than the crashed component the content scrolls internally rather than expanding the wrapper and pushing siblings.

  6. onError callback bridges the class/hooks boundary. The class ErrorTrigger catches synchronously via getDerivedStateFromError, then calls onError() to flip state in the parent shell. This is the only reliable way to use useLayoutEffect-based locks alongside a class error boundary in React 18 without resorting to a full class conversion.

Anatomy of the no-shift swap #

Dimension-lock swap sequence Timeline showing useLayoutEffect locking dimensions, then error boundary catching and mounting fallback inside the locked wrapper, while the parent layout sees no geometry change. Browser BoundaryShell ErrorTrigger Parent Layout after first paint getBoundingClientRect() → lock size render children throw Error → onError() setHasError(true) — size already held mount <fallback> inside wrapper contain:layout+size → no reflow CLS delta = 0

Edge cases #

Scenario Risk Mitigation
SSR / React Server Components useLayoutEffect is a no-op on the server; the wrapper renders without locked dimensions, causing a brief client-side measurement gap. Server-render the wrapper with aspect-ratio matching the expected content box and patch pixel values in useLayoutEffect immediately after hydration.
Nested error boundaries sharing a container Two sibling boundaries both applying contain: size can compete and produce different effective sizes, triggering a recalculation on the shared ancestor. Wrap sibling boundaries in a grid cell with contain: layout so each cell is independent.
Dynamic font loading affecting fallback text A custom font that loads after the fallback mounts can cause a text-block reflow inside the locked container and, on some browsers, a small shift if the container underflows. Add font-display: optional for fallback-only fonts, or preload the font file with <link rel="preload" as="font">.
Viewport resize invalidating locked dimensions A window resize fires during the fallback window, and the stale locked size no longer matches the surrounding grid. The ResizeObserver in useDimensionLock re-measures and updates; ensure the locked size state update is batched with flushSync only if strict CLS control is needed.

Verification steps #

Chrome DevTools — Layout Shift attribution:

  1. Open DevTools → Performance tab → record a page load.
  2. In the summary pane filter for “Layout Shift” entries.
  3. Click each entry: the “Sources” panel names the shifted element. Entries attributed to [data-boundary-shell] indicate the containment is not applied or the lock fired too late.

Lighthouse CLI:

npx lighthouse http://localhost:3000/your-page \
  --only-categories=performance \
  --output=json \
  | jq '.audits["cumulative-layout-shift"].numericValue'

Target: <= 0.05 (Good) or 0.0 if the boundary is the only interactive region on the page.

Playwright CLS assertion:

import { test, expect } from '@playwright/test';

test('error boundary fallback causes zero CLS', async ({ page }) => {
  await page.goto('/dashboard');

  // Inject a deterministic crash into the target widget
  await page.evaluate(() => {
    (window as any).__injectBoundaryError?.('data-widget-id', 'price-chart');
  });

  // Collect layout-shift entries via PerformanceObserver
  const cls = await page.evaluate((): Promise<number> =>
    new Promise((resolve) => {
      let score = 0;
      const obs = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          score += (entry as PerformanceEntry & { value: number }).value;
        }
      });
      obs.observe({ type: 'layout-shift', buffered: true });
      // Allow two animation frames for the boundary swap to complete
      requestAnimationFrame(() => requestAnimationFrame(() => resolve(score)));
    })
  );

  expect(cls).toBeLessThanOrEqual(0.05);
});

Console log pattern (quick sanity check in dev):

// Add to your boundary's componentDidCatch or onError callback during development
performance.getEntriesByType('layout-shift').forEach((e) => {
  const shift = e as PerformanceEntry & { value: number; sources?: { node?: Node }[] };
  if (shift.value > 0) {
    console.warn('[CLS] shift detected after boundary swap', shift.value, shift.sources);
  }
});

Frequently Asked Questions #

Why does my fallback still shift even though I set a fixed height? Fixed height alone does not stop the layout engine from recalculating when the wrapper lacks CSS containment. Adding contain: layout size paint prevents geometry from escaping the subtree regardless of the fallback’s intrinsic size. Height alone constrains the element but does not prevent ancestor reflow.

Does this technique work with SSR / React Server Components? The dimension-locking hook runs only on the client — in useLayoutEffect — so the server renders the wrapper without inline dimensions. To avoid a hydration mismatch, server-render the wrapper with the same contain and aspect-ratio CSS but without pixel values; the hook patches them immediately after hydration before the browser paints.

How do I handle a fallback that is taller than the original component? Allow overflow: auto on the locked container rather than overflow: hidden. The layout footprint stays constant; the fallback scrolls internally rather than pushing sibling elements down.