Devlog 18: Error Handling - Making Failures Graceful Instead of Catastrophic

6 min read
error-handling resilience ux

Devlog 18: Error Handling - Making Failures Graceful Instead of Catastrophic

Things break. Supabase goes down. Network connections drop. Users lose WiFi mid-vote. I can't prevent these failures, but I can handle them gracefully.

The goal: when something breaks, the app should degrade smoothly instead of showing a blank error screen.

The Failure Modes

Network errors:

  • User loses connection mid-vote
  • Supabase realtime disconnects
  • API requests timeout

Database errors:

  • Supabase is down (rare but happens)
  • Rate limits exceeded
  • Constraint violations

Client errors:

  • localStorage quota exceeded
  • WebGL context lost
  • Out of memory

Each needs different handling.

Retry Logic with Exponential Backoff

For transient errors (network glitches), retry with backoff:

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === maxRetries - 1) throw error;

      const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw new Error('Max retries exceeded');
}

// Usage
const votes = await retryWithBackoff(() =>
  supabase.from('votes').select('*')
);

This handles temporary network issues without bothering the user.

Offline Vote Queue

If votes fail to send, queue them locally:

interface QueuedVote {
  matchupId: string;
  side: 'a' | 'b';
  amount: number;
  timestamp: number;
}

async function castVote(side: 'a' | 'b', amount: number) {
  try {
    await supabase.from('votes').insert({
      matchup_id: matchupId,
      device_id: deviceId,
      side,
      amount
    });
  } catch (error) {
    // Failed - queue for later
    const queue = getVoteQueue();
    queue.push({
      matchupId,
      side,
      amount,
      timestamp: Date.now()
    });
    saveVoteQueue(queue);

    showToast('Offline - vote will sync when reconnected');
  }
}

When connection returns, flush the queue:

window.addEventListener('online', async () => {
  const queue = getVoteQueue();

  for (const vote of queue) {
    try {
      await supabase.from('votes').insert(vote);
    } catch (error) {
      console.error('Failed to sync vote:', error);
    }
  }

  clearVoteQueue();
  showToast('Votes synced!');
});

Users can keep voting offline, everything syncs later.

Realtime Reconnection

Supabase realtime connections drop occasionally. Handle reconnects:

const channel = supabase.channel(`matchup:${matchupId}`)
  .on('postgres_changes', { ... }, handleUpdate)
  .subscribe((status) => {
    if (status === 'SUBSCRIBED') {
      console.log('Connected to realtime');
    } else if (status === 'CHANNEL_ERROR') {
      console.error('Realtime error, reconnecting...');
      setTimeout(() => channel.subscribe(), 1000);
    } else if (status === 'TIMED_OUT') {
      console.error('Realtime timeout, reconnecting...');
      setTimeout(() => channel.subscribe(), 2000);
    }
  });

Auto-reconnect with a delay. User never notices.

Fallback for Real-Time Failures

If realtime won't connect, fall back to polling:

let realtimeActive = false;
let pollingInterval: number | null = null;

channel.subscribe((status) => {
  if (status === 'SUBSCRIBED') {
    realtimeActive = true;
    if (pollingInterval) {
      clearInterval(pollingInterval);
      pollingInterval = null;
    }
  } else if (status === 'CHANNEL_ERROR') {
    realtimeActive = false;
    startPolling();
  }
});

function startPolling() {
  if (pollingInterval) return;

  pollingInterval = setInterval(async () => {
    const { data } = await supabase
      .from('matchups')
      .select('*')
      .eq('id', matchupId)
      .single();

    updateMatchupData(data);
  }, 5000); // Poll every 5s
}

Realtime is better but polling works if realtime fails.

User-Friendly Error Messages

Never show raw error messages:

function getUserFriendlyError(error: Error): string {
  const message = error.message.toLowerCase();

  if (message.includes('network') || message.includes('fetch')) {
    return 'Connection issue. Check your internet and try again.';
  }

  if (message.includes('rate limit')) {
    return "You're voting too fast! Take a quick break.";
  }

  if (message.includes('constraint')) {
    return 'Invalid input. Please try again.';
  }

  if (message.includes('timeout')) {
    return 'Request timed out. Please try again.';
  }

  // Generic fallback
  return 'Something went wrong. Please try again.';
}

Friendly messages don't scare users.

Toast Notifications

Show non-blocking notifications for errors:

function showToast(message: string, type: 'info' | 'error' | 'success') {
  const toast = document.createElement('div');
  toast.className = `toast toast-${type}`;
  toast.textContent = message;
  document.body.appendChild(toast);

  setTimeout(() => {
    toast.classList.add('fade-out');
    setTimeout(() => toast.remove(), 300);
  }, 3000);
}

Non-intrusive way to communicate errors.

Partial Degradation

If some features fail, disable them but keep the app running:

let particlesAvailable = true;

try {
  const app = new PIXI.Application({ ... });
} catch (error) {
  console.error('WebGL failed:', error);
  particlesAvailable = false;
  showToast('Visual effects disabled due to browser limitations');
}

function spawnParticles() {
  if (!particlesAvailable) return; // Skip silently
  // ... spawn particles
}

No particles? Fine, voting still works.

Error Boundaries

Svelte doesn't have error boundaries like React, so I made one:

<script>
  import { onMount } from 'svelte';

  let hasError = false;
  let errorMessage = '';

  onMount(() => {
    window.addEventListener('error', (e) => {
      hasError = true;
      errorMessage = getUserFriendlyError(e.error);
    });
  });
</script>

{#if hasError}
  <div class="error-boundary">
    <h2>Oops! Something went wrong</h2>
    <p>{errorMessage}</p>
    <button on:click={() => window.location.reload()}>
      Reload Page
    </button>
  </div>
{:else}
  <slot />
{/if}

Catches unhandled errors and shows recovery UI.

localStorage Quota Exceeded

localStorage has a 5-10MB limit. Handle quota errors:

function saveToLocalStorage(key: string, value: any) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
  } catch (error) {
    if (error.name === 'QuotaExceededError') {
      // Clear old data
      const keys = Object.keys(localStorage);
      keys.sort((a, b) => {
        const aTime = localStorage.getItem(a + '_timestamp') || '0';
        const bTime = localStorage.getItem(b + '_timestamp') || '0';
        return parseInt(aTime) - parseInt(bTime);
      });

      // Remove oldest 25%
      const toRemove = Math.floor(keys.length * 0.25);
      for (let i = 0; i < toRemove; i++) {
        localStorage.removeItem(keys[i]);
      }

      // Retry
      localStorage.setItem(key, JSON.stringify(value));
    } else {
      throw error;
    }
  }
}

Auto-cleanup when quota exceeded.

Monitoring & Alerting

Track error rates to detect issues:

function logError(error: Error, context: any) {
  // Send to analytics
  trackEvent('error', {
    message: error.message,
    stack: error.stack,
    context: JSON.stringify(context)
  });

  // Send to error tracking service
  if (window.Sentry) {
    Sentry.captureException(error);
  }
}

Get alerts if error rates spike.

Takeaway

Error handling isn't about preventing errors. It's about recovering gracefully when they happen.

Retry with exponential backoff handles transient failures automatically.

Queue and sync patterns let users keep working offline.

Always have fallbacks. Realtime fails? Poll. WebGL breaks? Skip particles. localStorage full? Clear old data.

User-friendly messages matter. Don't show "TypeError: Cannot read property 'map' of undefined". Show "Something went wrong. Please refresh."

Partial degradation beats total failure. Disable broken features, keep core functionality working.

Next up: state management patterns with Svelte stores.