Front-end Engineering Lab

Graceful Degradation

Maintain functionality when features fail with smart fallback strategies

Graceful degradation ensures your app remains functional even when parts fail. Instead of showing error screens, degrade gracefully by providing fallback experiences.

Core Concept

Goal: Keep the app usable, even with reduced functionality.

// ❌ BAD: All or nothing
function Dashboard() {
  const analytics = fetchAnalytics(); // Throws on failure
  const notifications = fetchNotifications(); // Throws on failure
  
  return (
    <>
      <Analytics data={analytics} />
      <Notifications data={notifications} />
    </>
  );
}
// If analytics fails → entire dashboard crashes

// ✅ GOOD: Graceful degradation
function Dashboard() {
  const { data: analytics, error: analyticsError } = useAnalytics();
  const { data: notifications, error: notificationsError } = useNotifications();
  
  return (
    <>
      {analyticsError ? (
        <AnalyticsPlaceholder message="Analytics temporarily unavailable" />
      ) : (
        <Analytics data={analytics} />
      )}
      
      {notificationsError ? (
        <NotificationsPlaceholder />
      ) : (
        <Notifications data={notifications} />
      )}
    </>
  );
}
// Analytics fails → show placeholder, rest of dashboard works

Fallback Strategies

1. Cached Data Fallback

// hooks/useDataWithCache.ts
import { useQuery } from '@tanstack/react-query';

export function useDataWithCache<T>(key: string, fetcher: () => Promise<T>) {
  return useQuery({
    queryKey: [key],
    queryFn: fetcher,
    
    // Use stale data while refetching
    staleTime: 5 * 60 * 1000, // 5 min
    
    // Keep data in cache
    cacheTime: 30 * 60 * 1000, // 30 min
    
    // Show stale data on error
    placeholderData: (previousData) => previousData,
    
    // Retry logic
    retry: 2,
  });
}

// Usage
function ProductList() {
  const { data, error, isStale } = useDataWithCache('products', fetchProducts);
  
  return (
    <div>
      {isStale && (
        <Banner type="warning">
          Showing cached data. Unable to refresh.
        </Banner>
      )}
      
      <Products data={data} />
    </div>
  );
}

2. Default/Static Fallback

// Provide sensible defaults when data unavailable
function UserProfile() {
  const { data: user, error } = useUser();
  
  // Use default profile when fetch fails
  const profile = user ?? {
    name: 'Guest User',
    avatar: '/default-avatar.png',
    preferences: getDefaultPreferences(),
  };
  
  return (
    <>
      {error && (
        <Notice>Using default profile. Unable to load your data.</Notice>
      )}
      <Profile user={profile} />
    </>
  );
}

3. Feature Detection Fallback

// components/VideoPlayer.tsx
'use client';

import { useState, useEffect } from 'react';

export function VideoPlayer({ src }: { src: string }) {
  const [supportsVideo, setSupportsVideo] = useState(true);
  const [error, setError] = useState(false);
  
  useEffect(() => {
    // Check if browser supports video
    const video = document.createElement('video');
    setSupportsVideo(video.canPlayType('video/mp4') !== '');
  }, []);
  
  if (!supportsVideo) {
    return (
      <div className="video-fallback">
        <p>Video not supported in your browser.</p>
        <a href={src} download>Download video</a>
      </div>
    );
  }
  
  if (error) {
    return (
      <div className="video-fallback">
        <p>Unable to load video.</p>
        <a href={src}>View video directly</a>
      </div>
    );
  }
  
  return (
    <video
      src={src}
      controls
      onError={() => setError(true)}
    />
  );
}

4. Progressive Enhancement

// Start with basic functionality, enhance when possible
function SearchBox() {
  const [query, setQuery] = useState('');
  const [suggestions, setSuggestions] = useState<string[]>([]);
  const [autocompleteAvailable, setAutocompleteAvailable] = useState(true);
  
  const fetchSuggestions = async (q: string) => {
    try {
      const results = await fetch(`/api/autocomplete?q=${q}`).then(r => r.json());
      setSuggestions(results);
    } catch (error) {
      // Autocomplete failed, disable feature
      setAutocompleteAvailable(false);
      setSuggestions([]);
    }
  };
  
  return (
    <div>
      <input
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          if (autocompleteAvailable) {
            fetchSuggestions(e.target.value);
          }
        }}
        placeholder="Search..."
      />
      
      {/* Search works with or without autocomplete */}
      {autocompleteAvailable && suggestions.length > 0 && (
        <SuggestionsList suggestions={suggestions} />
      )}
      
      <button onClick={() => performSearch(query)}>
        Search
      </button>
    </div>
  );
}

Service-Level Degradation

External Service Fallback

// lib/image-service.ts
export async function getOptimizedImage(url: string) {
  try {
    // Try CDN first
    return await fetch(`https://cdn.example.com/optimize?url=${url}`)
      .then(r => r.json());
  } catch (error) {
    console.warn('CDN unavailable, using original image');
    
    // Fallback to original image
    return {
      url: url,
      optimized: false,
    };
  }
}

// Usage
function ProductImage({ src }: { src: string }) {
  const { data } = useQuery(['image', src], () => getOptimizedImage(src));
  
  return (
    <div>
      <img src={data?.url || src} alt="Product" />
      {!data?.optimized && (
        <small>High-quality image optimization unavailable</small>
      )}
    </div>
  );
}

Analytics Degradation

// lib/analytics.ts
class Analytics {
  private available = true;
  
  track(event: string, properties?: Record<string, any>) {
    if (!this.available) {
      // Analytics failed, log locally for debugging
      console.log('[Analytics (degraded)]', event, properties);
      return;
    }
    
    try {
      // Try to send to analytics service
      if (typeof gtag !== 'undefined') {
        gtag('event', event, properties);
      } else {
        throw new Error('gtag not available');
      }
    } catch (error) {
      console.warn('Analytics unavailable, degrading gracefully');
      this.available = false;
      
      // Don't break the app, just lose analytics
      console.log('[Analytics (degraded)]', event, properties);
    }
  }
}

export const analytics = new Analytics();

// Usage - App continues even if analytics fails
function ProductPage() {
  useEffect(() => {
    analytics.track('page_view', { page: 'product' });
  }, []);
  
  return <Product />;
}

UI Degradation Patterns

Skeleton → Error → Retry

// components/DegradableContent.tsx
'use client';

import { useState } from 'react';

interface Props {
  fetchData: () => Promise<any>;
  renderData: (data: any) => React.ReactNode;
  renderSkeleton: () => React.ReactNode;
  renderFallback?: (error: Error) => React.ReactNode;
}

export function DegradableContent({
  fetchData,
  renderData,
  renderSkeleton,
  renderFallback,
}: Props) {
  const [state, setState] = useState<{
    status: 'loading' | 'success' | 'error' | 'degraded';
    data?: any;
    error?: Error;
  }>({ status: 'loading' });
  
  useEffect(() => {
    fetchData()
      .then(data => setState({ status: 'success', data }))
      .catch(error => setState({ status: 'error', error }));
  }, []);
  
  if (state.status === 'loading') {
    return <>{renderSkeleton()}</>;
  }
  
  if (state.status === 'success') {
    return <>{renderData(state.data)}</>;
  }
  
  if (state.status === 'error') {
    if (renderFallback) {
      return <>{renderFallback(state.error!)}</>;
    }
    
    return (
      <div className="degraded-content">
        <p>Unable to load content</p>
        <button onClick={() => setState({ status: 'loading' })}>
          Try Again
        </button>
      </div>
    );
  }
  
  return null;
}

Progressive Image Loading

// components/ProgressiveImage.tsx
'use client';

import { useState } from 'react';
import Image from 'next/image';

interface Props {
  src: string;
  lowQualitySrc?: string;
  alt: string;
}

export function ProgressiveImage({ src, lowQualitySrc, alt }: Props) {
  const [imageError, setImageError] = useState(false);
  const [highQualityLoaded, setHighQualityLoaded] = useState(false);
  
  if (imageError) {
    // Ultimate fallback: colored placeholder
    return (
      <div className="image-placeholder">
        <span>{alt}</span>
      </div>
    );
  }
  
  return (
    <div className="progressive-image">
      {/* Low quality placeholder (loads fast) */}
      {lowQualitySrc && !highQualityLoaded && (
        <Image
          src={lowQualitySrc}
          alt={alt}
          className="blur"
          fill
        />
      )}
      
      {/* High quality image */}
      <Image
        src={src}
        alt={alt}
        fill
        onLoad={() => setHighQualityLoaded(true)}
        onError={() => setImageError(true)}
      />
    </div>
  );
}

API Response Degradation

Partial Data Handling

// Handle partial API failures
interface DashboardData {
  user: UserData;
  stats?: Stats;  // Optional
  notifications?: Notification[];  // Optional
  activity?: Activity[];  // Optional
}

async function fetchDashboard(): Promise<DashboardData> {
  const [user, stats, notifications, activity] = await Promise.allSettled([
    fetchUser(),
    fetchStats(),
    fetchNotifications(),
    fetchActivity(),
  ]);
  
  // User data is required
  if (user.status === 'rejected') {
    throw new Error('Failed to load user data');
  }
  
  // Other data is optional - use undefined if failed
  return {
    user: user.value,
    stats: stats.status === 'fulfilled' ? stats.value : undefined,
    notifications: notifications.status === 'fulfilled' ? notifications.value : undefined,
    activity: activity.status === 'fulfilled' ? activity.value : undefined,
  };
}

// Component handles missing data gracefully
function Dashboard() {
  const { data } = useQuery('dashboard', fetchDashboard);
  
  return (
    <div>
      <UserProfile user={data.user} />
      
      {data.stats ? (
        <Stats data={data.stats} />
      ) : (
        <StatsPlaceholder message="Stats unavailable" />
      )}
      
      {data.notifications ? (
        <Notifications data={data.notifications} />
      ) : (
        <NotificationsPlaceholder />
      )}
      
      {data.activity ? (
        <Activity data={data.activity} />
      ) : (
        <ActivityPlaceholder />
      )}
    </div>
  );
}

Timeout with Fallback

// lib/fetch-with-timeout.ts
export async function fetchWithTimeout<T>(
  url: string,
  options: RequestInit = {},
  timeout = 5000
): Promise<T> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    
    clearTimeout(timeoutId);
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    return response.json();
  } catch (error) {
    clearTimeout(timeoutId);
    throw error;
  }
}

// Usage with fallback
async function getRecommendations() {
  try {
    return await fetchWithTimeout('/api/recommendations', {}, 3000);
  } catch (error) {
    console.warn('Recommendations timeout, using defaults');
    
    // Fallback to static recommendations
    return getDefaultRecommendations();
  }
}

Network-Aware Degradation

// hooks/useNetworkAwareDegradation.ts
import { useState, useEffect } from 'react';

export function useNetworkAwareDegradation() {
  const [quality, setQuality] = useState<'high' | 'low' | 'offline'>('high');
  
  useEffect(() => {
    const updateQuality = () => {
      if (!navigator.onLine) {
        setQuality('offline');
        return;
      }
      
      const connection = (navigator as any).connection;
      if (!connection) {
        setQuality('high');
        return;
      }
      
      const { effectiveType, saveData } = connection;
      
      if (saveData || effectiveType === 'slow-2g' || effectiveType === '2g') {
        setQuality('low');
      } else {
        setQuality('high');
      }
    };
    
    updateQuality();
    
    window.addEventListener('online', updateQuality);
    window.addEventListener('offline', updateQuality);
    
    const connection = (navigator as any).connection;
    if (connection) {
      connection.addEventListener('change', updateQuality);
    }
    
    return () => {
      window.removeEventListener('online', updateQuality);
      window.removeEventListener('offline', updateQuality);
      if (connection) {
        connection.removeEventListener('change', updateQuality);
      }
    };
  }, []);
  
  return quality;
}

// Usage
function AdaptiveContent() {
  const quality = useNetworkAwareDegradation();
  
  if (quality === 'offline') {
    return <OfflineContent />;
  }
  
  if (quality === 'low') {
    return <LowQualityContent />; // Smaller images, less data
  }
  
  return <HighQualityContent />; // Full experience
}

Degradation Levels

// Define degradation tiers
type DegradationLevel = 'full' | 'partial' | 'minimal' | 'offline';

interface AppState {
  degradationLevel: DegradationLevel;
  features: {
    analytics: boolean;
    recommendations: boolean;
    realtime: boolean;
    highQualityImages: boolean;
  };
}

function getDegradationLevel(): AppState {
  const online = navigator.onLine;
  const connection = (navigator as any).connection;
  
  if (!online) {
    return {
      degradationLevel: 'offline',
      features: {
        analytics: false,
        recommendations: false,
        realtime: false,
        highQualityImages: false,
      },
    };
  }
  
  // Check service availability
  const servicesHealth = checkServicesHealth();
  
  if (servicesHealth.critical < 0.5) {
    return {
      degradationLevel: 'minimal',
      features: {
        analytics: false,
        recommendations: false,
        realtime: false,
        highQualityImages: false,
      },
    };
  }
  
  if (servicesHealth.important < 0.7) {
    return {
      degradationLevel: 'partial',
      features: {
        analytics: true,
        recommendations: false,
        realtime: false,
        highQualityImages: true,
      },
    };
  }
  
  return {
    degradationLevel: 'full',
    features: {
      analytics: true,
      recommendations: true,
      realtime: true,
      highQualityImages: true,
    },
  };
}

Best Practices

  1. Prioritize Core Features: Keep essential features working
  2. Use Cached Data: Better than nothing
  3. Inform Users: Show when degraded
  4. Progressive Enhancement: Start simple, add features
  5. Test Degraded States: Simulate failures
  6. Fail Independently: One feature fails ≠ all fail
  7. Provide Alternatives: Offer different ways to accomplish tasks

Common Pitfalls

All or nothing: Everything fails together
Independent features

Silent degradation: User doesn't know what's happening
Show degradation notices

No fallback data: Showing nothing
Use cached/default data

Breaking core features: Payment, auth fail
Protect critical paths

Degradation Hierarchy

  1. Critical (Never degrade): Auth, payments, data integrity
  2. Important (Degrade gracefully): User profile, checkout flow
  3. Nice-to-have (Degrade freely): Analytics, recommendations, animations
  4. Optional (Disable first): Real-time updates, high-quality images

Graceful degradation is about resilience—keeping your app useful even when things go wrong.

On this page