Error Boundaries in React 19: Catching AI Feature Failures Before They Cascade Across Your SaaS Tenant
Here’s the painful truth: I shipped CitizenApp with error boundaries treating all failures the same. Then Claude’s API hit rate limits during peak hours, and an inference timeout in one AI feature crashed the entire dashboard for that tenant. Users couldn’t access their data, couldn’t switch features, couldn’t do anything. One API call cascaded into a complete tenant lockout.
That’s when I realized error boundaries aren’t optional UX polish—they’re architectural load-bearing walls for AI-heavy SaaS apps. Unlike traditional React crashes (which are rare), AI feature failures happen constantly: rate limits, context length overflows, hallucination detection rejections, network flakes. You need granular, recoverable boundaries around each one.
This post shows how I rebuilt CitizenApp’s error handling to isolate Claude failures at the feature level while preserving dashboard state and allowing graceful degradation.
Why Standard Error Boundaries Fail for AI Features
Traditional React error boundaries catch render errors—they’re blast radius tools. One corrupted state object crashes the whole tree. But AI features fail differently:
- They fail in async handlers, not during render
- They fail frequently and recoverable (rate limit → retry in 30s)
- Their failures cascade sideways, not just down (a document generation timeout leaves the user’s edit form in limbo)
- Recovery state matters more than the crash itself (you need to know which exact inference failed, what inputs caused it, whether to retry)
Standard boundaries can’t handle this. You need:
- Feature-level isolation (one failing AI call doesn’t touch another feature’s state)
- Inference-specific error context (which model, which prompt, which tenant)
- Async error capture (boundaries only catch render; async errors slip through)
- State preservation (user’s form data stays intact; only the inference result is poisoned)
The Architecture: Nested Error Boundaries + Async Error Capture
I use three layers:
Layer 1: Feature Error Boundaries wrap individual AI features (document generator, summarizer, etc.) Layer 2: Async Error Handlers capture inference failures in try-catch and dispatch to error state Layer 3: Global Fallback catches unexpected render crashes (safety net)
Here’s what CitizenApp uses:
// lib/errors.ts - AI-specific error types
export class AIFeatureError extends Error {
constructor(
public featureId: string,
public inferenceId: string,
public tenantId: string,
public originalError: Error,
public isRetryable: boolean,
message: string,
) {
super(message);
this.name = 'AIFeatureError';
}
}
export class RateLimitError extends AIFeatureError {
constructor(
featureId: string,
inferenceId: string,
tenantId: string,
public retryAfterSeconds: number,
) {
super(
featureId,
inferenceId,
tenantId,
new Error(`Rate limited, retry after ${retryAfterSeconds}s`),
true,
`AI feature rate limited. Retrying in ${retryAfterSeconds}s...`,
);
this.name = 'RateLimitError';
}
}
export class ContextLengthError extends AIFeatureError {
constructor(featureId: string, inferenceId: string, tenantId: string) {
super(
featureId,
inferenceId,
tenantId,
new Error('Input context exceeds model limit'),
false, // not retryable
'Document too long for this AI feature. Try splitting into smaller sections.',
);
this.name = 'ContextLengthError';
}
}
// components/FeatureErrorBoundary.tsx
import React, { ReactNode } from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: AIFeatureError | null;
errorCount: number;
}
interface Props {
children: ReactNode;
featureId: string;
tenantId: string;
onError?: (error: AIFeatureError) => void;
fallback?: (error: AIFeatureError, retry: () => void) => ReactNode;
}
export class FeatureErrorBoundary extends React.Component<
Props,
ErrorBoundaryState
> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorCount: 0 };
}
static getDerivedStateFromError(error: Error) {
// Only handle AIFeatureError. Let others propagate.
if (error instanceof AIFeatureError) {
return { hasError: true, error };
}
throw error;
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
if (error instanceof AIFeatureError) {
console.error(
`[${this.props.featureId}] AI Feature Error:`,
error.inferenceId,
error,
);
// Report to observability (Sentry, LogRocket, etc.)
this.reportError(error, errorInfo);
// Callback for parent to react (e.g., disable retry button)
this.props.onError?.(error);
// Don't retry more than 3 times per feature mount
if (this.state.errorCount >= 3) {
console.warn(
`[${this.props.featureId}] Exceeded max retries, giving up`,
);
}
}
}
private reportError = (error: AIFeatureError, errorInfo: React.ErrorInfo) => {
// Your observability pipeline
fetch('/api/errors/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
featureId: this.props.featureId,
tenantId: this.props.tenantId,
inferenceId: error.inferenceId,
errorType: error.name,
message: error.message,
isRetryable: error.isRetryable,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
}),
});
};
handleReset = () => {
this.setState((prev) => ({
hasError: false,
error: null,
errorCount: prev.errorCount + 1,
}));
};
render() {
if (this.state.hasError && this.state.error) {
return (
this.props.fallback?.(this.state.error, this.handleReset) || (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 className="font-semibold text-red-900">
{this.state.error.message}
</h3>
<p className="text-sm text-red-700 mt-1">
ID: {this.state.error.inferenceId}
</p>
{this.state.error.isRetryable && (
<button
onClick={this.handleReset}
className="mt-3 px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700"
>
Retry
</button>
)}
</div>
)
);
}
return this.props.children;
}
}
Now the critical part: async error capture. Boundaries don’t catch async failures, so you need explicit error dispatch:
// hooks/useAIFeature.ts - handles inference + error dispatch
import { useState, useCallback } from 'react';
import { AIFeatureError, RateLimitError, ContextLengthError } from '@/lib/errors';
interface UseAIFeatureOptions {
featureId: string;
tenantId: string;
onError?: (error: AIFeatureError) => void;
}
export function useAIFeature<T>({
featureId,
tenantId,
onError,
}: UseAIFeatureOptions) {
const [state, setState] = useState<{
loading: boolean;
data: T | null;
error: AIFeatureError | null;
}>({
loading: false,
data: null,
error: null,
});
const invoke = useCallback(
async (input: string, options?: { maxRetries?: number }) => {
const inferenceId = crypto.randomUUID();
const maxRetries = options?.maxRetries ?? 3;
let lastError: AIFeatureError | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
setState((prev) => ({ ...prev, loading: true, error: null }));
const response = await fetch(
`/api/tenants/${tenantId}/ai-features/${featureId}/invoke`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, inferenceId, attempt }),
},
);
if (!response.ok) {
const errorData = await response.json();
// Handle rate limits with exponential backoff
if (response.status === 429) {
const retryAfter = parseInt(
response.headers.get('Retry-After') ?? '30',
);
lastError = new RateLimitError(
featureId,
inferenceId,
tenantId,
retryAfter,
);
if (attempt < maxRetries) {
await new Promise((r) =>
setTimeout(r, retryAfter * 1000 * Math.pow(1.5, attempt)),
);
continue;
}
}
// Handle context length