Back to Blog
December 15, 2025

Graceful Degradation vs Progressive Enhancement: Why Your Solo Project Needs Both

You shipped your MVP. It works perfectly on your M2 MacBook Pro running Chrome. Then someone tweets: "Tried your app on my Android tablet. Half the buttons don't work?"

Graceful Degradation vs Progressive Enhancement - Distracted Boyfriend meme with solopreneurs, progressive enhancement, and shiny new framework

Welcome to the solopreneur's nightmare: building for the web means building for everywhere. But you can't afford a QA team testing on 47 device-browser combinations. You need a strategy that's pragmatic, not perfect.

The Reality Check: What These Terms Actually Mean

Progressive Enhancement: Start with a baseline experience that works everywhere (HTML forms, semantic markup), then layer on JavaScript enhancements for capable browsers.

Graceful Degradation: Build the full experience first, then add fallbacks for browsers that can't handle modern features.

The dogma says: "Progressive enhancement is The Right Way™." The reality? You need both, depending on what you're building.

Pattern 1: The Form That Actually Works (Progressive Enhancement)

Let's look at how Cal.com handles their booking form. The core experience works without JavaScript:

<!-- Base HTML that works everywhere -->
<form action="/api/book" method="POST">
  <input 
    type="datetime-local" 
    name="datetime" 
    required 
  />
  <input type="email" name="email" required />
  <button type="submit">Book Appointment</button>
</form>

<script>
  // Enhancement: client-side validation + instant feedback
  if ('IntersectionObserver' in window) {
    const form = document.querySelector('form');
    
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      
      // Show loading state
      const button = form.querySelector('button');
      button.disabled = true;
      button.textContent = 'Booking...';
      
      // Submit via fetch with better UX
      const formData = new FormData(form);
      const response = await fetch('/api/book', {
        method: 'POST',
        body: formData
      });
      
      if (response.ok) {
        // Show success without page reload
        showConfetti();
      }
    });
  }
</script>

Why this works: If JavaScript fails to load (slow 3G, script blocker, ancient browser), the form still submits. The server handles it. You haven't locked anyone out.

Time investment: 15 minutes to wrap your existing form logic.

Pattern 2: Feature Detection Without the Bloat (Graceful Degradation)

Linear's editor uses cutting-edge browser APIs, but they detect support first:

// Check for modern clipboard API
const canUseModernClipboard = () => {
  return (
    'clipboard' in navigator &&
    'write' in navigator.clipboard
  );
};

// Use modern API with fallback
async function copyToClipboard(text) {
  if (canUseModernClipboard()) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (err) {
      // Permission denied or other error - fall through
    }
  }
  
  // Fallback: old-school text selection
  const textarea = document.createElement('textarea');
  textarea.value = text;
  textarea.style.position = 'fixed';
  textarea.style.opacity = '0';
  document.body.appendChild(textarea);
  textarea.select();
  
  try {
    document.execCommand('copy');
    return true;
  } finally {
    document.body.removeChild(textarea);
  }
}

// Usage - same interface everywhere
button.onclick = () => {
  copyToClipboard(secretToken);
  showToast('Copied!');
};

Why this works: You get the modern API benefits (better permissions UX, no selection flash) where available, but the feature still works in IE11 if someone's corporate IT is stuck in 2013.

Time investment: 20 minutes including testing.

Pattern 3: The Analytics-Driven Approach (What Actually Matters)

Notion doesn't test on every browser. They use real data:

// Track feature support on real user devices
const trackFeatureSupport = () => {
  const features = {
    webp: document.createElement('canvas')
      .toDataURL('image/webp')
      .indexOf('data:image/webp') === 0,
    
    grid: CSS.supports('display: grid'),
    
    intersectionObserver: 'IntersectionObserver' in window,
    
    serviceWorker: 'serviceWorker' in navigator,
  };
  
  // Send to your analytics (Plausible, PostHog, etc.)
  analytics.track('browser_capabilities', features);
  
  return features;
};

// On page load, track once per session
if (!sessionStorage.getItem('features_tracked')) {
  trackFeatureSupport();
  sessionStorage.setItem('features_tracked', 'true');
}

The insight: After two weeks, you'll know if 0.01% or 15% of your users lack WebP support. Prioritize fallbacks accordingly.

Time investment: 10 minutes setup, saves hours on unnecessary fallbacks.

The Framework-Agnostic Wrapper Pattern

Works with React, Vue, Svelte, or vanilla JS:

// features.js - Your feature detection library
export const features = {
  clipboard: 'clipboard' in navigator,
  webp: null, // Check async
  localStorage: (() => {
    try {
      localStorage.setItem('test', 'test');
      localStorage.removeItem('test');
      return true;
    } catch {
      return false;
    }
  })(),
};

// React example
function CopyButton({ text }) {
  const [supported, setSupported] = useState(features.clipboard);
  
  if (!supported) {
    // Fallback UI: show the text to copy manually
    return (
      <div className="copy-fallback">
        <code>{text}</code>
        <small>Copy this manually</small>
      </div>
    );
  }
  
  return (
    <button onClick={() => copyToClipboard(text)}>
      Copy to Clipboard
    </button>
  );
}

// Vue example
<template>
  <button v-if="features.clipboard" @click="copy">
    Copy
  </button>
  <code v-else>{{ text }}</code>
</template>

// Svelte example
{#if features.clipboard}
  <button on:click={copy}>Copy</button>
{:else}
  <code>{text}</code>
{/if}

The Pragmatic Decision Matrix

FeatureApproachWhy
Forms, navigation, core actionsProgressive EnhancementMust work without JS
Rich text editor, canvas toolsGraceful DegradationNo non-JS equivalent
Image formats (WebP, AVIF)Check analytics firstEasy fallback if needed
CSS Grid vs FlexboxProgressive EnhancementFlexbox is universal now
Service WorkersGraceful DegradationEnhancement only

The 30-Minute Implementation Checklist

  1. Add feature detection (5 min): Create a features.js file with detection for APIs you use.
  2. Wrap critical features (10 min): Add detection checks before using modern APIs.
  3. Implement 1-2 key fallbacks (10 min): Focus on features that break core functionality.
  4. Add analytics tracking (5 min): Track what your real users actually support.

That's it. You don't need to test on 47 browsers. You need to know what breaks, have a fallback, and let data guide you.

What This Looks Like in Production

A real example from an indie SaaS dashboard:

// App loads, checks features once
const userCapabilities = {
  canUseOffline: 'serviceWorker' in navigator,
  canCopyRich: 'ClipboardItem' in window,
  canShareNative: 'share' in navigator,
  hasWebP: await checkWebPSupport(),
};

// Now just check flags throughout your app
if (userCapabilities.canShareNative) {
  showShareButton();
} else {
  showCopyLinkButton(); // Works everywhere
}

// Image component uses the capability
function ProjectImage({ src }) {
  const format = userCapabilities.hasWebP ? 'webp' : 'jpg';
  return <img src={`${src}.${format}`} />;
}

The Real Takeaway

Stop treating progressive enhancement and graceful degradation as religious warfare. They're tools. Use progressive enhancement for must-work features (forms, navigation, content). Use graceful degradation for nice-to-have features (animations, rich interactions, offline mode).

Most importantly: use analytics to decide what matters. If 0.1% of your users lack JavaScript, maybe a full no-JS experience isn't worth the engineering time. If 20% lack WebP support, definitely add that fallback.

Your time is finite. Your users' browsers are not. Build smart, not exhaustive.


Building resilient web apps as a solopreneur? Desplega.ai automates the QA testing you don't have time for—so you can focus on shipping features, not debugging edge cases.