This page narrows a scenario from Component Isolation Techniques: containing the blast radius of a single embedded widget you do not control, rather than isolating first-party components.
The exact failure this page solves #
A support-chat widget, an ads slot, or a third-party reviews carousel throws during render — a null dereference in the vendor’s own bundle, an unexpected API response shape, a race between its own async init and React’s commit phase. Without isolation, that throw unwinds up the component tree until it hits the nearest boundary, which is often the app root. The checkout form, the navigation, the rest of the page — everything unmounts because of a widget that was never critical to the page’s core function. The fix is a SandboxBoundary component: a small, reusable boundary purpose-built for embedding untrusted or flaky third-party UI, which renders a compact fallback in place of just the widget and reports the failure without touching anything else on the page.
Zero-to-working implementation #
The component below is complete and framework-accurate for React 18/19. It catches synchronous render/lifecycle errors, reserves layout space to avoid shift, reports to telemetry, and supports a bounded retry.
// SandboxBoundary.tsx
import React from 'react';
interface SandboxBoundaryProps {
children: React.ReactNode;
name: string; // widget identifier used in telemetry and the fallback message
maxRetries?: number; // caps re-mount attempts to avoid a retry storm
minHeight?: number; // reserved pixel height so the fallback causes no layout shift
}
interface SandboxBoundaryState {
hasError: boolean;
retryCount: number;
}
export class SandboxBoundary extends React.Component<SandboxBoundaryProps, SandboxBoundaryState> {
static defaultProps = { maxRetries: 1, minHeight: 120 };
state: SandboxBoundaryState = { hasError: false, retryCount: 0 };
static getDerivedStateFromError(): Partial<SandboxBoundaryState> {
return { hasError: true }; // flips render to the fallback branch below
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
reportWidgetCrash(this.props.name, error, info.componentStack, this.state.retryCount);
}
private retry = (): void => {
this.setState(prev => ({ hasError: false, retryCount: prev.retryCount + 1 }));
};
render(): React.ReactNode {
const { hasError, retryCount } = this.state;
const { name, maxRetries = 1, minHeight = 120, children } = this.props;
if (hasError) {
const canRetry = retryCount < maxRetries;
return (
<div
role="alert"
style={{ minHeight, display: 'flex', alignItems: 'center', gap: '0.5rem',
border: '1px dashed currentColor', opacity: 0.85,
fontSize: '0.85rem', padding: '0.75rem 1rem' }}
>
<span>{name} is unavailable right now.</span>
{canRetry && <button type="button" onClick={this.retry}>Retry</button>}
</div>
);
}
// Reserve the widget's footprint even before it throws, so a later
// crash swaps content in place rather than collapsing the container.
return <div style={{ minHeight }}>{children}</div>;
}
}
function reportWidgetCrash(
name: string,
error: Error,
componentStack: string | null,
attempt: number,
): void {
const payload = JSON.stringify({
name, message: error.message, stack: error.stack, componentStack, attempt, at: Date.now(),
});
fetch('/telemetry/widget-crash', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true, // ensures the beacon survives if the page navigates away
}).catch(() => { /* telemetry is best-effort; never throw from the reporter */ });
}
Usage is a single wrap at the call site:
<SandboxBoundary name="support-chat-widget" maxRetries={2} minHeight={96}>
<SupportChatWidget />
</SandboxBoundary>
Step-by-step explanation #
-
getDerivedStateFromErrorflips render state. This static lifecycle method runs during the render phase, beforecomponentDidCatch, and its only job is to sethasError: trueso the next render produces the fallback branch instead of re-throwing. -
componentDidCatchreports, it does not render. This method runs during the commit phase and receives both theErrorand React’scomponentStack, which pinpoints which JSX ancestor chain was mounting the widget — invaluable when the same vendor script is embedded in multiple places on the page. -
The fallback reserves
minHeightbefore and after the crash. The non-error branch already wrapschildrenin adivsized tominHeight, so swapping to the fallbackdivof the same height produces zero layout shift — the rest of the page never reflows. -
reportWidgetCrashuses akeepalivefetch. Ordinary fetches can be cancelled mid-flight if the user navigates immediately after the crash;keepalive: truetells the browser to complete the request in the background, similar tonavigator.sendBeacon. -
retryre-mounts the subtree, capped bymaxRetries. SettinghasError: falsecauses React to attempt mountingchildrenagain from scratch — a fresh instance of the widget, not a resumed one. ThecanRetrycheck disables the button once the cap is hit, so a widget that fails on every mount stops offering false hope. -
The boundary only guards the render call stack. Nothing in this component subscribes to
window’s error events — that gap is deliberate and covered in the edge cases below, because conflating the two catch paths is the most common mistake when isolating third-party code.
Edge cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
Widget throws inside a setTimeout, event handler, or its own fetch().then() |
Error reaches window as uncaught; boundary’s fallback never appears |
Add a window.addEventListener('error', ...) and unhandledrejection listener scoped to the widget’s script, and route matches into the same reportWidgetCrash call |
| Vendor script mutates global objects or leaks a memory-hungry loop | Boundary catches the render throw, but the damage already happened outside React | Load fully untrusted vendor code inside an <iframe sandbox="allow-scripts">, communicating over postMessage instead of direct DOM/JS access |
Widget fails on every mount and retry is called repeatedly by the user or an automated poller |
Repeated componentDidCatch calls flood telemetry and hammer the vendor’s endpoint |
Enforce maxRetries, and add an exponential backoff timer before re-enabling the button rather than allowing back-to-back clicks |
| Fallback renders at a different size than the loaded widget | Page reflows the moment the widget crashes or recovers | Set minHeight to the widget’s typical rendered height (measure with DevTools) so both the loading, success, and fallback states occupy the same box |
Verification steps #
-
Force the widget to throw. Temporarily replace the wrapped component with one that throws unconditionally:
function BrokenWidget(): never { throw new Error('forced crash'); }, mount it inside<SandboxBoundary name="test">, and confirm only the boundary’s fallback appears — the rest of the page must remain scrollable and interactive. -
Check the Network tab. Confirm a
POST /telemetry/widget-crashrequest fires withname,message, andcomponentStackpopulated, and that it completes even if you immediately trigger a client-side navigation afterward (thanks tokeepalive). -
Click Retry and confirm re-mount. React DevTools’ Components tab should show the widget subtree unmount and remount with a fresh instance; click through
maxRetriestimes and confirm the button disappears afterward. -
Confirm the async gap. Add a
setTimeout(() => { throw new Error('async'); }, 0)inside the test widget and verify the boundary’s fallback does not appear — the error should surface only in the console or yourwindow.onerrorlistener, proving the boundary’s catch scope is exactly the render call stack. -
Measure layout shift. Use Chrome DevTools’ Performance panel or a Cumulative Layout Shift overlay while triggering the crash; the reserved
minHeightshould keep CLS contribution from this component at zero.
Frequently asked questions #
Will a SandboxBoundary catch an error from a widget’s setTimeout callback?
No. React error boundaries only catch errors thrown during rendering, in lifecycle methods, or in constructors of the components below them. Anything thrown inside a setTimeout, a promise callback, or a DOM event handler runs outside React’s render call stack and becomes an uncaught exception on window instead of reaching componentDidCatch.
Should I always run third-party widgets inside an iframe instead of a boundary?
Use an iframe with a sandbox attribute when the vendor script is untrusted or known to touch global state, since it gives you real process and DOM isolation that a boundary cannot provide. Reach for a SandboxBoundary alone when the widget is a reasonably trusted vendor component rendered directly in your React tree and the goal is mainly to contain synchronous render crashes without the overhead and messaging complexity of a separate browsing context.
How do I stop a flaky widget from retrying itself into a retry storm?
Cap retries with the maxRetries prop, disable the retry action once the cap is reached, and consider adding a short backoff delay between attempts rather than allowing rapid repeated clicks. Logging every retry attempt to telemetry, as this implementation does with the attempt field, turns a widget that fails on every mount into a visible monitoring signal instead of a silent background loop.
Related #
- Component Isolation Techniques — the parent guide on scoping boundaries around individual components rather than whole routes
- Best Practices for Global vs Local Error Boundaries in SPAs — deciding when a root-level boundary is enough versus when a local one like this is required
- Choosing a Boundary Granularity Strategy — a framework for deciding how many boundaries a page needs and where to place them