Designing a Frontend That Survives Partial Backend Failures
February 25, 20262 min read7 views
Designing a Frontend That Survives Partial Backend Failures
Graceful degradation, fallback UI, skeleton vs stale data decisions, error boundaries as isolation zones, and circuit breaker patterns in the client. Building resilience into every layer.
The Partial Failure Reality
Distributed systems fail partially. When your frontend depends on 10 microservices, the probability that at least one is degraded approaches certainty:
┌─────────────────────────────────────────────────────────────────┐
│ Partial Failure Scenarios │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Homepage depends on: │
│ ├── Auth Service (99.9% uptime → 8.7 hrs/yr down) │
│ ├── Product Service (99.9% uptime → 8.7 hrs/yr down) │
│ ├── Recommendations (99.5% uptime → 43.8 hrs/yr down) │
│ ├── Inventory Service (99.9% uptime → 8.7 hrs/yr down) │
│ ├── Pricing Service (99.9% uptime → 8.7 hrs/yr down) │
│ ├── Reviews Service (99.5% uptime → 43.8 hrs/yr down) │
│ ├── Search Service (99.9% uptime → 8.7 hrs/yr down) │
│ └── Analytics Service (99.0% uptime → 87.6 hrs/yr down) │
│ │
│ Combined probability of ALL services up: │
│ 0.999 × 0.999 × 0.995 × 0.999 × 0.999 × 0.995 × 0.999 × 0.99 │
│ = 97.5% uptime │
│ = 219 hours/year of partial degradation │
│ │
│ Without resilience: Page fails if ANY service fails │
│ With resilience: Page works with degraded functionality │
│ │
└─────────────────────────────────────────────────────────────────┘
Resilience Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Frontend Resilience Layers │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User Interface │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Error Boundaries (Component Isolation) │ │
│ │ • Catch render errors │ │
│ │ • Show fallback UI │ │
│ │ • Prevent cascade failures │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────▼───────────────────────────────┐ │
│ │ Data Fetching Layer (React Query / SWR) │ │
│ │ • Automatic retries │ │
│ │ • Stale-while-revalidate │ │
│ │ • Error state management │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────▼───────────────────────────────┐ │
│ │ Circuit Breakers │ │
│ │ • Prevent repeated failed requests │ │
│ │ • Fast-fail when service is down │ │
│ │ • Auto-recovery when service returns │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────▼───────────────────────────────┐ │
│ │ Fallback Data Sources │ │
│ │ • Local cache (Service Worker) │ │
│ │ • IndexedDB │ │
│ │ • Static fallback data │ │
│ └─────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ Backend APIs │
│ │
└─────────────────────────────────────────────────────────────────┘
Error Boundaries as Isolation Zones
Strategic Boundary Placement
// app/layout.tsx - Root level catches catastrophic failures
import { ErrorBoundary } from 'react-error-boundary';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ErrorBoundary
fallback={<CatastrophicErrorPage />}
onError={(error) => {
// This is bad - entire app failed
errorReporter.critical(error, { level: 'root' });
}}
>
{children}
</ErrorBoundary>
</body>
</html>
);
}
// Page level - isolates page-specific failures
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<>
<Header /> {/* Shared, should always work */}
<ErrorBoundary
fallback={<ProductErrorFallback productId={params.id} />}
onReset={() => window.location.reload()}
>
<ProductContent productId={params.id} />
</ErrorBoundary>
<Footer /> {/* Shared, should always work */}
</>
);
}
// Component level - most granular isolation
function ProductContent({ productId }: { productId: string }) {
return (
<div className="product-page">
{/* Critical - fail entire component if this fails */}
<ProductHeader productId={productId} />
{/* Non-critical - isolate failures */}
<ErrorBoundary fallback={<RecommendationsFallback />}>
<Recommendations productId={productId} />
</ErrorBoundary>
<ErrorBoundary fallback={<ReviewsFallback />}>
<ProductReviews productId={productId} />
</ErrorBoundary>
<ErrorBoundary fallback={<QAFallback />}>
<ProductQA productId={productId} />
</ErrorBoundary>
</div>
);
}
Custom Error Boundary with Recovery
// components/resilient-boundary.tsx
interface ResilientBoundaryProps {
children: React.ReactNode;
fallback: React.ReactNode;
fallbackData?: () => Promise<unknown>;
retryable?: boolean;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
isolation?: 'strict' | 'lenient';
}
interface ResilientBoundaryState {
hasError: boolean;
error: Error | null;
errorCount: number;
lastErrorTime: number;
fallbackData: unknown | null;
}
class ResilientBoundary extends React.Component<
ResilientBoundaryProps,
ResilientBoundaryState
> {
state: ResilientBoundaryState = {
hasError: false,
error: null,
errorCount: 0,
lastErrorTime: 0,
fallbackData: null,
};
static getDerivedStateFromError(error: Error): Partial<ResilientBoundaryState> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState((prev) => ({
errorCount: prev.errorCount + 1,
lastErrorTime: Date.now(),
}));
// Try to load fallback data
if (this.props.fallbackData) {
this.props.fallbackData()
.then((data) => this.setState({ fallbackData: data }))
.catch(() => {}); // Ignore fallback failures
}
// Report error
this.props.onError?.(error, errorInfo);
// Log with context
console.error('[ResilientBoundary] Component error:', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
errorCount: this.state.errorCount + 1,
});
}
handleRetry = () => {
// Exponential backoff for rapid failures
const timeSinceLastError = Date.now() - this.state.lastErrorTime;
const minWait = Math.min(1000 * Math.pow(2, this.state.errorCount), 30000);
if (timeSinceLastError < minWait) {
console.log(`[ResilientBoundary] Retry throttled, wait ${minWait - timeSinceLastError}ms`);
return;
}
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
// If we have fallback data, render with it
if (this.state.fallbackData && this.props.isolation === 'lenient') {
return (
<FallbackDataContext.Provider value={this.state.fallbackData}>
{this.props.children}
</FallbackDataContext.Provider>
);
}
// Otherwise show fallback UI
return (
<div className="error-boundary-fallback">
{this.props.fallback}
{this.props.retryable && (
<button onClick={this.handleRetry}>
Try Again
</button>
)}
</div>
);
}
return this.props.children;
}
}
Circuit Breakers
Client-Side Circuit Breaker
// lib/circuit-breaker.ts
type CircuitState = 'closed' | 'open' | 'half-open';
interface CircuitBreakerConfig {
failureThreshold: number; // Failures before opening
resetTimeout: number; // Time before attempting reset (ms)
halfOpenMaxAttempts: number; // Successful requests to close
monitorWindow: number; // Time window for failure counting (ms)
}
interface CircuitStats {
failures: number;
successes: number;
lastFailure: number | null;
lastSuccess: number | null;
}
class CircuitBreaker {
private state: CircuitState = 'closed';
private stats: CircuitStats = {
failures: 0,
successes: 0,
lastFailure: null,
lastSuccess: null,
};
private halfOpenAttempts = 0;
private failureTimestamps: number[] = [];
constructor(
private name: string,
private config: CircuitBreakerConfig
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
// Check state
if (this.state === 'open') {
if (this.shouldAttemptReset()) {
this.transitionTo('half-open');
} else {
throw new CircuitOpenError(this.name, this.getTimeUntilReset());
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure(error as Error);
throw error;
}
}
private onSuccess(): void {
this.stats.successes++;
this.stats.lastSuccess = Date.now();
if (this.state === 'half-open') {
this.halfOpenAttempts++;
if (this.halfOpenAttempts >= this.config.halfOpenMaxAttempts) {
this.transitionTo('closed');
}
} else if (this.state === 'closed') {
// Reset failure count on success
this.clearOldFailures();
}
}
private onFailure(error: Error): void {
this.stats.failures++;
this.stats.lastFailure = Date.now();
this.failureTimestamps.push(Date.now());
// Clear old failures outside monitoring window
this.clearOldFailures();
if (this.state === 'half-open') {
// Any failure in half-open reopens circuit
this.transitionTo('open');
} else if (this.state === 'closed') {
// Check if we've exceeded threshold
if (this.failureTimestamps.length >= this.config.failureThreshold) {
this.transitionTo('open');
}
}
}
private clearOldFailures(): void {
const cutoff = Date.now() - this.config.monitorWindow;
this.failureTimestamps = this.failureTimestamps.filter((t) => t > cutoff);
}
private shouldAttemptReset(): boolean {
if (!this.stats.lastFailure) return true;
return Date.now() - this.stats.lastFailure >= this.config.resetTimeout;
}
private getTimeUntilReset(): number {
if (!this.stats.lastFailure) return 0;
return Math.max(
0,
this.config.resetTimeout - (Date.now() - this.stats.lastFailure)
);
}
private transitionTo(newState: CircuitState): void {
const oldState = this.state;
this.state = newState;
if (newState === 'half-open') {
this.halfOpenAttempts = 0;
}
if (newState === 'closed') {
this.failureTimestamps = [];
}
// Emit event
window.dispatchEvent(
new CustomEvent('circuit-state-change', {
detail: {
name: this.name,
from: oldState,
to: newState,
stats: { ...this.stats },
},
})
);
console.log(`[CircuitBreaker:${this.name}] ${oldState} → ${newState}`);
}
getState(): CircuitState {
return this.state;
}
getStats(): CircuitStats & { state: CircuitState } {
return { ...this.stats, state: this.state };
}
}
class CircuitOpenError extends Error {
constructor(
public circuitName: string,
public retryAfter: number
) {
super(`Circuit ${circuitName} is open. Retry after ${retryAfter}ms`);
this.name = 'CircuitOpenError';
}
}
// Create circuit breakers for each service
export const circuits = {
products: new CircuitBreaker('products', {
failureThreshold: 5,
resetTimeout: 30000,
halfOpenMaxAttempts: 3,
monitorWindow: 60000,
}),
recommendations: new CircuitBreaker('recommendations', {
failureThreshold: 3,
resetTimeout: 15000,
halfOpenMaxAttempts: 2,
monitorWindow: 30000,
}),
reviews: new CircuitBreaker('reviews', {
failureThreshold: 3,
resetTimeout: 15000,
halfOpenMaxAttempts: 2,
monitorWindow: 30000,
}),
};
// Usage
async function fetchProducts(): Promise<Product[]> {
return circuits.products.execute(() =>
fetch('/api/products').then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
);
}
React Query Integration
// hooks/use-resilient-query.ts
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { CircuitBreaker, CircuitOpenError } from '@/lib/circuit-breaker';
interface ResilientQueryOptions<T> extends UseQueryOptions<T> {
circuit: CircuitBreaker;
fallbackData?: T;
degradedMode?: boolean;
}
export function useResilientQuery<T>(
key: unknown[],
fetcher: () => Promise<T>,
options: ResilientQueryOptions<T>
) {
const { circuit, fallbackData, degradedMode, ...queryOptions } = options;
return useQuery({
queryKey: key,
queryFn: async () => {
try {
return await circuit.execute(fetcher);
} catch (error) {
if (error instanceof CircuitOpenError) {
// Circuit is open - return fallback immediately
if (fallbackData !== undefined) {
return fallbackData;
}
throw error;
}
throw error;
}
},
...queryOptions,
// Keep showing stale data on error
placeholderData: (previousData) => previousData,
// Retry configuration
retry: (failureCount, error) => {
if (error instanceof CircuitOpenError) {
return false; // Don't retry if circuit is open
}
return failureCount < 3;
},
// Meta for degraded mode indication
meta: {
degraded: circuit.getState() !== 'closed',
},
});
}
// Usage
function ProductRecommendations({ productId }: { productId: string }) {
const { data, isError, isFetching, meta } = useResilientQuery(
['recommendations', productId],
() => fetchRecommendations(productId),
{
circuit: circuits.recommendations,
fallbackData: [], // Empty array if circuit is open
staleTime: 5 * 60 * 1000,
}
);
if (meta?.degraded) {
return (
<div className="degraded-notice">
<p>Recommendations temporarily unavailable</p>
</div>
);
}
return <RecommendationGrid items={data ?? []} />;
}
Fallback Strategies
Strategy 1: Skeleton UI
// Show loading skeleton, then error state if fails
function ProductCard({ productId }: { productId: string }) {
const { data, isLoading, isError } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
if (isLoading) {
return <ProductCardSkeleton />;
}
if (isError) {
return <ProductCardError productId={productId} />;
}
return <ProductCardContent product={data} />;
}
function ProductCardSkeleton() {
return (
<div className="product-card skeleton">
<div className="skeleton-image" />
<div className="skeleton-title" />
<div className="skeleton-price" />
</div>
);
}
Strategy 2: Stale Data
// Show stale data while fetching, only show error if no data
function ProductPrice({ productId }: { productId: string }) {
const { data, isLoading, isError, isStale, dataUpdatedAt } = useQuery({
queryKey: ['price', productId],
queryFn: () => fetchPrice(productId),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
// Show stale data with indicator
if (data) {
const staleness = Date.now() - dataUpdatedAt;
return (
<div className="price">
<span className={isStale ? 'stale' : ''}>${data.price}</span>
{isStale && (
<span className="stale-indicator">
Price from {formatRelative(dataUpdatedAt)}
{isLoading && <Spinner size="sm" />}
</span>
)}
</div>
);
}
// No data available
if (isLoading) {
return <PriceSkeleton />;
}
if (isError) {
return <span className="price-unavailable">Price unavailable</span>;
}
return null;
}
Strategy 3: Static Fallback
// Pre-computed fallback data
const STATIC_FALLBACKS = {
categories: [
{ id: 'electronics', name: 'Electronics', count: '1000+' },
{ id: 'clothing', name: 'Clothing', count: '500+' },
{ id: 'home', name: 'Home & Garden', count: '750+' },
],
featuredProducts: [
{ id: '1', name: 'Loading...', price: '--', image: '/placeholder.jpg' },
],
};
function Categories() {
const { data, isError } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
});
// Use static fallback if fetch fails
const categories = isError ? STATIC_FALLBACKS.categories : data;
return (
<div className={isError ? 'degraded' : ''}>
{isError && (
<Notice>Using cached data. Some categories may be outdated.</Notice>
)}
<CategoryGrid categories={categories} />
</div>
);
}
Strategy 4: Cached Fallback (Service Worker)
// sw.ts
const FALLBACK_CACHE = 'fallback-v1';
// Pre-cache critical API responses
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(FALLBACK_CACHE).then((cache) =>
cache.addAll([
'/api/categories',
'/api/featured-products',
'/api/navigation',
])
)
);
});
// Serve from cache on network failure
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Update cache with fresh response
if (response.ok) {
const clone = response.clone();
caches.open(FALLBACK_CACHE).then((cache) => {
cache.put(event.request, clone);
});
}
return response;
})
.catch(async () => {
// Network failed, try cache
const cached = await caches.match(event.request);
if (cached) {
// Add header to indicate cached response
const headers = new Headers(cached.headers);
headers.set('X-Fallback-Cache', 'true');
headers.set('X-Cached-At', cached.headers.get('date') ?? '');
return new Response(cached.body, {
status: cached.status,
headers,
});
}
// No cache, return error response
return new Response(
JSON.stringify({ error: 'Service unavailable', offline: true }),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
}
);
})
);
}
});
// Client-side detection
async function fetchWithFallbackDetection<T>(url: string): Promise<{
data: T;
fromCache: boolean;
cachedAt: Date | null;
}> {
const response = await fetch(url);
const fromCache = response.headers.get('X-Fallback-Cache') === 'true';
const cachedAt = response.headers.get('X-Cached-At');
return {
data: await response.json(),
fromCache,
cachedAt: cachedAt ? new Date(cachedAt) : null,
};
}
Graceful Degradation Patterns
Feature Degradation Matrix
// Define degradation levels for each feature
type DegradationLevel = 'full' | 'reduced' | 'minimal' | 'disabled';
interface FeatureDegradation {
feature: string;
level: DegradationLevel;
dependencies: string[];
fallbackBehavior: () => void;
}
const DEGRADATION_RULES: Record<string, FeatureDegradation[]> = {
// When recommendations service fails
recommendations: [
{
feature: 'personalized-recommendations',
level: 'disabled',
dependencies: ['recommendations'],
fallbackBehavior: () => showPopularProducts(),
},
{
feature: 'recently-viewed',
level: 'reduced',
dependencies: ['recommendations'],
fallbackBehavior: () => showFromLocalStorage(),
},
],
// When inventory service fails
inventory: [
{
feature: 'real-time-stock',
level: 'disabled',
dependencies: ['inventory'],
fallbackBehavior: () => hideStockIndicator(),
},
{
feature: 'add-to-cart',
level: 'reduced', // Allow but warn
dependencies: ['inventory'],
fallbackBehavior: () => addWithStockWarning(),
},
],
// When reviews service fails
reviews: [
{
feature: 'reviews-section',
level: 'disabled',
dependencies: ['reviews'],
fallbackBehavior: () => showReviewsUnavailable(),
},
{
feature: 'star-rating',
level: 'reduced',
dependencies: ['reviews'],
fallbackBehavior: () => showCachedRating(),
},
],
};
class DegradationManager {
private degradedServices: Set<string> = new Set();
degrade(service: string): void {
this.degradedServices.add(service);
this.applyDegradations();
}
restore(service: string): void {
this.degradedServices.delete(service);
this.applyDegradations();
}
private applyDegradations(): void {
const allFeatures = new Map<string, DegradationLevel>();
// Calculate degradation level for each feature
for (const service of this.degradedServices) {
const rules = DEGRADATION_RULES[service] ?? [];
for (const rule of rules) {
const current = allFeatures.get(rule.feature);
const newLevel = this.worseLevel(current, rule.level);
allFeatures.set(rule.feature, newLevel);
}
}
// Apply degradations
for (const [feature, level] of allFeatures) {
this.applyFeatureDegradation(feature, level);
}
}
private worseLevel(
a: DegradationLevel | undefined,
b: DegradationLevel
): DegradationLevel {
const order: DegradationLevel[] = ['full', 'reduced', 'minimal', 'disabled'];
const aIndex = a ? order.indexOf(a) : 0;
const bIndex = order.indexOf(b);
return order[Math.max(aIndex, bIndex)];
}
private applyFeatureDegradation(feature: string, level: DegradationLevel): void {
window.dispatchEvent(
new CustomEvent('feature-degradation', {
detail: { feature, level },
})
);
}
}
export const degradationManager = new DegradationManager();
// React hook
export function useFeatureDegradation(feature: string): DegradationLevel {
const [level, setLevel] = useState<DegradationLevel>('full');
useEffect(() => {
const handler = (event: CustomEvent) => {
if (event.detail.feature === feature) {
setLevel(event.detail.level);
}
};
window.addEventListener('feature-degradation', handler as EventListener);
return () => window.removeEventListener('feature-degradation', handler as EventListener);
}, [feature]);
return level;
}
Progressive Feature Loading
// Load non-critical features only when resources allow
function ProductPage({ productId }: { productId: string }) {
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const isOnline = useOnlineStatus();
const connectionType = useConnectionType();
// Critical sections - always load
const criticalSections = (
<>
<ProductHeader productId={productId} />
<ProductImages productId={productId} />
<ProductPrice productId={productId} />
<AddToCart productId={productId} />
</>
);
// Progressive sections - load based on conditions
const progressiveSections = useMemo(() => {
const sections = [];
// Reviews - load if online and not on slow connection
if (isOnline && connectionType !== '2g') {
sections.push(
<LazySection
key="reviews"
id="reviews"
onLoad={() => setLoadedSections((s) => new Set(s).add('reviews'))}
>
<ProductReviews productId={productId} />
</LazySection>
);
}
// Recommendations - only on fast connections
if (isOnline && ['4g', 'wifi'].includes(connectionType)) {
sections.push(
<LazySection
key="recommendations"
id="recommendations"
onLoad={() => setLoadedSections((s) => new Set(s).add('recommendations'))}
>
<Recommendations productId={productId} />
</LazySection>
);
}
// Q&A - only if main content loaded successfully
if (loadedSections.has('reviews')) {
sections.push(
<LazySection key="qa" id="qa">
<ProductQA productId={productId} />
</LazySection>
);
}
return sections;
}, [isOnline, connectionType, productId, loadedSections]);
return (
<div className="product-page">
{criticalSections}
<Suspense fallback={<SectionsSkeleton count={progressiveSections.length} />}>
{progressiveSections}
</Suspense>
</div>
);
}
Health Monitoring
// lib/health-monitor.ts
interface ServiceHealth {
service: string;
status: 'healthy' | 'degraded' | 'unhealthy';
latency: number;
errorRate: number;
lastCheck: number;
circuitState: CircuitState;
}
class HealthMonitor {
private health: Map<string, ServiceHealth> = new Map();
private checkInterval: number = 30000; // 30 seconds
private listeners: Set<(health: Map<string, ServiceHealth>) => void> = new Set();
start(): void {
// Initial check
this.checkAll();
// Periodic checks
setInterval(() => this.checkAll(), this.checkInterval);
// Listen for circuit breaker changes
window.addEventListener('circuit-state-change', (event: CustomEvent) => {
const { name, to, stats } = event.detail;
this.updateHealth(name, {
circuitState: to,
errorRate: stats.failures / (stats.successes + stats.failures + 1),
});
});
}
private async checkAll(): Promise<void> {
const services = ['products', 'recommendations', 'reviews', 'inventory'];
await Promise.all(services.map((service) => this.checkService(service)));
// Notify listeners
this.listeners.forEach((listener) => listener(new Map(this.health)));
}
private async checkService(service: string): Promise<void> {
const start = Date.now();
try {
const response = await fetch(`/api/${service}/health`, {
signal: AbortSignal.timeout(5000),
});
const latency = Date.now() - start;
const healthy = response.ok;
this.updateHealth(service, {
status: healthy ? 'healthy' : 'degraded',
latency,
lastCheck: Date.now(),
});
} catch {
this.updateHealth(service, {
status: 'unhealthy',
latency: Date.now() - start,
lastCheck: Date.now(),
});
}
}
private updateHealth(service: string, updates: Partial<ServiceHealth>): void {
const current = this.health.get(service) ?? {
service,
status: 'healthy',
latency: 0,
errorRate: 0,
lastCheck: 0,
circuitState: 'closed' as CircuitState,
};
this.health.set(service, { ...current, ...updates });
// Auto-degrade features based on health
if (updates.status === 'unhealthy') {
degradationManager.degrade(service);
} else if (updates.status === 'healthy') {
degradationManager.restore(service);
}
}
subscribe(listener: (health: Map<string, ServiceHealth>) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
getHealth(service: string): ServiceHealth | undefined {
return this.health.get(service);
}
}
export const healthMonitor = new HealthMonitor();
// React hook
export function useServiceHealth(service: string): ServiceHealth | undefined {
const [health, setHealth] = useState<ServiceHealth | undefined>(
healthMonitor.getHealth(service)
);
useEffect(() => {
return healthMonitor.subscribe((allHealth) => {
setHealth(allHealth.get(service));
});
}, [service]);
return health;
}
// Status indicator component
function ServiceStatusIndicator({ service }: { service: string }) {
const health = useServiceHealth(service);
if (!health) return null;
const colors = {
healthy: 'green',
degraded: 'yellow',
unhealthy: 'red',
};
return (
<div
className="status-indicator"
style={{ backgroundColor: colors[health.status] }}
title={`${service}: ${health.status} (${health.latency}ms)`}
/>
);
}
Summary
Building frontend resilience requires:
| Layer | Pattern | Implementation |
|---|---|---|
| Component | Error Boundaries | Isolate failures to smallest possible scope |
| Data | Circuit Breakers | Prevent cascading failures from slow/failed services |
| UI | Graceful Degradation | Show reduced functionality rather than nothing |
| Cache | Fallback Sources | Service Worker, IndexedDB, static data |
| Monitoring | Health Checks | Proactive detection and auto-degradation |
Key principles:
- Fail small — Error boundaries at component level
- Fail fast — Circuit breakers prevent wasted requests
- Fail gracefully — Show something useful even when services are down
- Fail visibly — Users should know when they're seeing degraded data
- Recover automatically — Half-open circuits, background revalidation
The goal: 100% of page loads succeed, even if only 80% of features are available. Users tolerate degradation; they don't tolerate blank screens.
What did you think?