StackPractices
intermediate Por Mathias Paulenko

Patrón Progressive Enhancement

Construye una base HTML funcional y añade capas de CSS y JavaScript encima. Cubre detección de características, degradación elegante y fallbacks sin JS.

Temas: frontend

Overview

La mejora progresiva (progressive enhancement) es una estrategia de entrega: publicas una página cuya función principal funciona con HTML plano, y encima añades capas de CSS para la presentación y de JavaScript para la interactividad. Steven Champeon acuñó el término en 2003, y la idea sobrevive a cada ciclo de frameworks porque trata sobre modos de fallo, no sobre tecnología.

La base cubre lo esencial: los formularios se envían, los enlaces navegan, el contenido se puede leer. Si un script no llega a descargarse por una conexión inestable, lo bloquea un proxy corporativo o falla en una API que el navegador no tiene, el usuario sigue recibiendo una página que funciona. JavaScript mejora esa página que ya funciona con validación en línea, envío asíncrono e interacciones más ricas, en lugar de ser un requisito previo para usarla.

El beneficio es triple. Los buscadores y los lectores de pantalla reciben el contenido completo sin ejecutar un byte de JavaScript. Los usuarios reales con dispositivos y redes limitadas obtienen algo usable de inmediato en lugar de una pantalla en blanco. Y el código gana una frontera de resiliencia natural: cuando una mejora falla, degrada hacia la base en lugar de degradar hacia la nada.

When to Use

  • Sitios web públicos donde el SEO y la accesibilidad importan
  • Formularios que deben funcionar incluso cuando JavaScript falla
  • Sitios de contenido donde el valor principal es la lectura
  • Aplicaciones dirigidas a usuarios con conexiones lentas o navegadores antiguos
  • Cualquier sitio donde la robustez pese más que las interacciones vistosas

Un formulario de contacto, un checkout, un servicio público, un sitio de documentación: esos son los casos clásicos. Si perder JavaScript significa que el usuario no puede completar su tarea, la mejora progresiva es el valor por defecto correcto. El manual de servicios de GOV.UK lleva años exigiendo este enfoque precisamente porque un servicio público no puede excluir a usuarios cuyos navegadores o redes fallan.

When NOT to Use

  • Aplicaciones muy interactivas (editores, juegos, chat) donde el HTML solo no aporta valor
  • Herramientas internas donde controlas el navegador y las condiciones de red
  • Dashboards en tiempo real que necesitan JavaScript para funcionar
  • Aplicaciones cuya propuesta de valor completa es la interactividad con JavaScript

Sé honesto con el límite. Un editor colaborativo de documentos, una herramienta de diseño sobre canvas o un dashboard WebGL no tienen una base HTML significativa a la que degradar. La mejora progresiva es para páginas y flujos, no para aplicaciones cuyo valor es el runtime en sí.

Solution

Layer 1: Functional HTML baseline

La base es un formulario de verdad: action, method, etiquetas y atributos required. Envía datos al servidor y funciona en Lynx, en un crawler y en una Blackberry de 2009.

<!-- contact-form.html — funciona sin JavaScript -->
<form action="/api/contact" method="POST" class="contact-form">
  <fieldset>
    <legend>Contact Us</legend>

    <div class="field">
      <label for="name">Name</label>
      <input type="text" id="name" name="name" required maxlength="100">
    </div>

    <div class="field">
      <label for="email">Email</label>
      <input type="email" id="email" name="email" required>
    </div>

    <div class="field">
      <label for="message">Message</label>
      <textarea id="message" name="message" required rows="5" maxlength="1000"></textarea>
    </div>

    <button type="submit">Send Message</button>
  </fieldset>
</form>

Dos detalles soportan todo el peso. Los atributos action y method hacen que el formulario se envíe a un destino real, y los atributos nativos required y type="email" te dan validación del navegador gratis antes de que se ejecute cualquier JavaScript.

Layer 2: CSS presentation

El CSS mejora la base sin cambiar su comportamiento. La única sutileza es el prefijo .js: los estilos de estados que solo existen con JavaScript (como los mensajes de error en línea) se limitan bajo .js, para que nunca se rendericen en la base sin JS donde nada los rellena.

/* contact-form.css — mejora la base */
.contact-form {
  max-width: 500px;
  margin: 2rem auto;
  padding: 2rem;
  background: white;
  border-radius: 0.5rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.contact-form fieldset {
  border: none;
  padding: 0;
}

.contact-form legend {
  font-size: 1.5rem;
  font-weight: 700;
  margin-bottom: 1rem;
}

.field {
  margin-bottom: 1rem;
}

.field label {
  display: block;
  font-weight: 500;
  margin-bottom: 0.25rem;
}

.field input,
.field textarea {
  width: 100%;
  padding: 0.5rem 0.75rem;
  border: 1px solid #cbd5e1;
  border-radius: 0.375rem;
  font-size: 1rem;
}

.field input:focus,
.field textarea:focus {
  outline: none;
  border-color: #3b82f6;
  box-shadow: 0 0 0 2px #dbeafe;
}

/* Solo muestra estados mejorados con JS cuando JS está disponible */
.js .field input.invalid {
  border-color: #ef4444;
}

.js .field .error-message {
  display: none;
  color: #ef4444;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

.js .field.invalid .error-message {
  display: block;
}

.contact-form button {
  width: 100%;
  padding: 0.625rem 1rem;
  background: #3b82f6;
  color: white;
  border: none;
  border-radius: 0.375rem;
  font-weight: 500;
  cursor: pointer;
}

.contact-form button:hover {
  background: #2563eb;
}

Mira el patrón de arquitectura CSS para ver cómo organizar estos selectores por capas a escala.

Layer 3: JavaScript enhancement

Lo primero que hace el script es añadir la clase js al <html>, lo que activa todo el CSS limitado por .js. Después intercepta el evento submit, ejecuta una validación en línea más amable y cambia la recarga completa por una llamada fetch. Si algo de eso falla, el formulario se envía a la antigua.

// contact-form.js — mejora el formulario con validación y envío asíncrono
document.documentElement.classList.add('js');

const form = document.querySelector('.contact-form');

if (form) {
  // Validación del lado del cliente
  const inputs = form.querySelectorAll('input, textarea');

  inputs.forEach(input => {
    input.addEventListener('blur', () => validateField(input));
    input.addEventListener('input', () => {
      if (input.classList.contains('invalid')) {
        validateField(input);
      }
    });
  });

  function validateField(input) {
    const field = input.closest('.field');
    let isValid = true;
    let message = '';

    if (input.required && !input.value.trim()) {
      isValid = false;
      message = 'Este campo es obligatorio.';
    } else if (input.type === 'email' && input.value) {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(input.value)) {
        isValid = false;
        message = 'Introduce una dirección de email válida.';
      }
    } else if (input.maxLength && input.value.length > input.maxLength) {
      isValid = false;
      message = `Máximo ${input.maxLength} caracteres.`;
    }

    field.classList.toggle('invalid', !isValid);

    let errorEl = field.querySelector('.error-message');
    if (!errorEl) {
      errorEl = document.createElement('span');
      errorEl.className = 'error-message';
      field.appendChild(errorEl);
    }
    errorEl.textContent = message;

    return isValid;
  }

  // Envío asíncrono — solo si JS está disponible
  form.addEventListener('submit', async (e) => {
    e.preventDefault();

    // Valida todos los campos
    let allValid = true;
    inputs.forEach(input => {
      if (!validateField(input)) {
        allValid = false;
      }
    });

    if (!allValid) return;

    const submitButton = form.querySelector('button[type="submit"]');
    submitButton.disabled = true;
    submitButton.textContent = 'Enviando...';

    try {
      const formData = new FormData(form);
      const response = await fetch(form.action, {
        method: form.method,
        body: formData,
      });

      if (response.ok) {
        form.innerHTML = `
          <div class="ql">
            <h2>¡Gracias!</h2>
            <p>Tu mensaje se ha enviado. Te responderemos pronto.</p>
          </div>
        `;
      } else {
        throw new Error('Error del servidor');
      }
    } catch (error) {
      submitButton.disabled = false;
      submitButton.textContent = 'Enviar mensaje';

      const errorDiv = document.createElement('div');
      errorDiv.className = 'form-error';
      errorDiv.textContent = 'No se pudo enviar. Inténtalo de nuevo.';
      form.insertBefore(errorDiv, form.firstChild);
    }
  });
}

Fíjate en que el servidor recibe un POST normal en ambos casos. La validación del cliente es una cortesía; la validación del servidor es el requisito. Nunca trates la validación en línea como una barrera de seguridad.

Feature detection antes de enhancement

La mejora es condicional a la capacidad, no a la identidad del navegador. Comprueba la API que realmente necesitas y ofrece un camino alternativo cuando falte.

// feature-detection.js — comprueba capacidades antes de mejorar
const features = {
  fetch: typeof fetch !== 'undefined',
  intersectionObserver: 'IntersectionObserver' in window,
  customElements: 'customElements' in window,
  webp: document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0,
  serviceWorker: 'serviceWorker' in navigator,
};

// Solo mejora si las características están disponibles
if (features.fetch) {
  enhanceFormSubmission();
}

if (features.intersectionObserver) {
  lazyLoadImages();
} else {
  // Fallback: carga todas las imágenes inmediatamente
  loadAllImages();
}

if (features.serviceWorker) {
  navigator.serviceWorker.register('/sw.js').catch(() => {
    // El service worker falló — el sitio sigue funcionando sin él
  });
}

function enhanceFormSubmission() {
  document.querySelectorAll('form[data-enhance]').forEach(form => {
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      const response = await fetch(form.action, {
        method: form.method,
        body: new FormData(form),
      });
      if (response.ok) {
        form.dispatchEvent(new CustomEvent('form:success', { detail: await response.json() }));
      }
    });
  });
}

function lazyLoadImages() {
  const images = document.querySelectorAll('img[data-src]');
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.removeAttribute('data-src');
        observer.unobserve(img);
      }
    });
  });
  images.forEach(img => observer.observe(img));
}

function loadAllImages() {
  document.querySelectorAll('img[data-src]').forEach(img => {
    img.src = img.dataset.src;
    img.removeAttribute('data-src');
  });
}

Cada if es una decisión de fallback, no una puerta: si falta IntersectionObserver, las imágenes cargan de forma ansiosa, que es más lento pero correcto. La detección de características gana al sniffing por navigator.userAgent porque prueba la capacidad directamente y sigue funcionando a medida que los navegadores cambian.

Los enlaces siguen siendo enlaces de verdad. La mejora intercepta los clics de mismo origen y cambia el contenido principal, con un fallback duro a la navegación normal si algo va mal.

<!-- nav.html — funciona como enlaces normales, mejorado a navegación tipo SPA -->
<nav class="main-nav">
  <a href="/" data-link>Home</a>
  <a href="/about" data-link>About</a>
  <a href="/products" data-link>Products</a>
  <a href="/contact" data-link>Contact</a>
</nav>
// nav-enhance.js — mejora los enlaces para navegación tipo SPA
if (features.fetch && features.customElements) {
  document.addEventListener('click', (e) => {
    const link = e.target.closest('a[data-link]');
    if (!link) return;

    // Solo maneja enlaces del mismo origen
    if (link.origin !== window.location.origin) return;

    e.preventDefault();

    loadPage(link.href);
  });

  async function loadPage(url) {
    const main = document.querySelector('main');

    // Muestra estado de carga
    main.style.opacity = '0.5';

    try {
      const response = await fetch(url);
      const html = await response.text();
      const parser = new DOMParser();
      const doc = parser.parseFromString(html, 'text/html');

      // Actualiza el contenido
      main.innerHTML = doc.querySelector('main').innerHTML;
      main.style.opacity = '1';

      // Actualiza URL y título
      window.history.pushState({}, '', url);
      document.title = doc.title;

      // Scroll arriba
      window.scrollTo(0, 0);
    } catch {
      // Fallback: navega normalmente
      window.location.href = url;
    }
  }

  // Maneja atrás/adelante
  window.addEventListener('popstate', () => {
    loadPage(window.location.href);
  });
}

Tres detalles lo hacen seguro: los enlaces externos pasan intactos, el catch degrada a window.location.href, y pushState mantiene los enlaces profundos compartibles. Es la misma idea detrás de Turbo, htmx y las transiciones de cliente de Next.js, solo que sin framework.

Table sorting enhancement

Una <table> con datos reales funciona para todos. La mejora añade ordenación por clic encima, con soporte de teclado para que la interacción no sea solo de ratón.

<!-- data-table.html — tabla funcional, mejorada con ordenación -->
<table class="data-table" data-enhance="sort">
  <thead>
    <tr>
      <th data-sort="name">Name</th>
      <th data-sort="email">Email</th>
      <th data-sort="date">Date</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Alice</td>
      <td>alice@example.com</td>
      <td>2026-01-15</td>
    </tr>
    <tr>
      <td>Bob</td>
      <td>bob@example.com</td>
      <td>2026-03-20</td>
    </tr>
  </tbody>
</table>
// table-sort.js — mejora la tabla con ordenación por clic
document.querySelectorAll('table[data-enhance="sort"]').forEach(table => {
  const headers = table.querySelectorAll('th[data-sort]');

  headers.forEach(header => {
    header.style.cursor = 'pointer';
    header.setAttribute('role', 'button');
    header.setAttribute('tabindex', '0');

    header.addEventListener('click', () => sortTable(table, header));
    header.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        sortTable(table, header);
      }
    });
  });

  function sortTable(table, header) {
    const tbody = table.querySelector('tbody');
    const rows = Array.from(tbody.querySelectorAll('tr'));
    const currentDir = header.dataset.dir || 'asc';
    const newDir = currentDir === 'asc' ? 'desc' : 'asc';

    rows.sort((a, b) => {
      const aVal = a.querySelector(`td:nth-child(${header.cellIndex + 1})`).textContent;
      const bVal = b.querySelector(`td:nth-child(${header.cellIndex + 1})`).textContent;
      return newDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
    });

    header.dataset.dir = newDir;
    rows.forEach(row => tbody.appendChild(row));
  }
});

How It Works

Cada mejora es una apuesta que puedes perder con elegancia. El patrón estructura la apuesta como un conjunto ordenado de capas con un fallback en cada paso.

Mermaid flowchart LR diagram

Tres consecuencias se derivan. Primero, la base es un producto, no un wireframe: tiene que publicarse, probarse y ser alcanzable en una URL. Segundo, cada mejora necesita una decisión explícita de fallback antes de mergearse; “funciona en Chrome” no es un fallback. Tercero, el manejo del lado del servidor es el suelo sobre el que todo se apoya, porque el camino sin JS envía directamente hacia él.

La mejora progresiva suele confundirse con la degradación elegante, y la diferencia está en el punto de partida:

Progressive enhancementGraceful degradationJS-required
Punto de partidaBase HTML que funcionaApp con todas las funcionesApp JavaScript
Si JS fallaLa base sigue funcionandoDepende de fallbacks añadidos despuésNada funciona
Foco de testingLa base primero, luego las capasPuntos de ruptura de la versión completaSolo el entorno JS
Resultado típicoLa base se publica por defectoLa base a menudo nunca se publicaPantalla en blanco al fallar

Los casos borde prácticos:

  • El fallo parcial de JS es común. Un timeout de CDN puede entregar HTML y CSS pero no JS, o un error de transpilación puede matar un bundle mientras otros sobreviven. El diseño por capas aísla el fallo en la capa que se rompió.
  • El CSS también puede fallar. Los proxies restrictivos bloquean hojas de estilo. El HTML semántico mantiene la página navegable incluso sin estilos.
  • Los lectores de pantalla no son la única capa asistiva. Crawlers, modos lectura, unfurlers de enlaces y vistas de impresión consumen todos la base.
  • La mejora puede mejorar lo incorrecto. Detecta la API que usas ('fetch' in window), no la que asumes que se correlaciona con ella.

Variants

Universal rendering con enhancement

El HTML renderizado en servidor con hidratación selectiva es mejora progresiva implementada por un framework en lugar de a mano. El servidor envía una página completa; el cliente adjunta comportamiento solo donde se necesita.

// HTML renderizado en servidor que se hidrata progresivamente
// El servidor envía HTML completo, el cliente mejora partes específicas
document.addEventListener('DOMContentLoaded', () => {
  // Solo mejora si el navegador soporta las APIs necesarias
  if (!('fetch' in window) || !('IntersectionObserver' in window)) {
    return; // El HTML base funciona bien
  }

  // Mejora la búsqueda
  const searchInput = document.querySelector('#search-input');
  if (searchInput) {
    enhanceSearch(searchInput);
  }

  // Mejora el carrito
  const cartButton = document.querySelector('#add-to-cart');
  if (cartButton) {
    enhanceCart(cartButton);
  }
});

El patrón de arquitectura de islas formaliza esto: estático por defecto, con islas interactivas que se hidratan de forma independiente, de modo que un fallo en una isla no tumba la página.

No-JS fallback con noscript

Para funciones que de verdad no pueden degradar (un gráfico en vivo, un editor WYSIWYG), <noscript> da un mensaje de fallback honesto en lugar de un widget roto.

<!-- Fallback para funciones dependientes de JavaScript -->
<noscript>
  <style>
    .js-only { display: none !important; }
    .no-js-only { display: block !important; }
  </style>
  <div class="no-js-only warning">
    Esta función requiere JavaScript. Parte de la funcionalidad puede estar limitada.
  </div>
</noscript>

<style>
  .no-js-only { display: none; }
</style>

Best Practices

La accesibilidad es la razón más fuerte para tomarse esto en serio: lectores de pantalla, usuarios de teclado y dispositivos de poca potencia se benefician de una base semántica. Para el panorama completo de cumplimiento, mira la guía completa de accesibilidad web.

  • Empieza con HTML que funciona: los formularios se envían, los enlaces navegan, el contenido se lee
  • Usa detección de características, no sniffing de navegador: comprueba if ('fetch' in window), no if (isChrome)
  • Añade la clase js al <html>: usa selectores .js .component para que los estilos mejorados solo apliquen cuando JS está disponible
  • Mejora, no reemplaces: JavaScript debería mejorar la base HTML, no sustituirla por algo que se rompe sin JS
  • Prueba con JavaScript deshabilitado: desactívalo en DevTools y verifica que la página sigue funcionando
  • Mantén la validación del lado del servidor: la del cliente es mejora; la del servidor es requisito
  • Usa atributos data-enhance: marca los elementos a mejorar para que la capa JS sepa qué tocar
  • Da estados de carga en operaciones asíncronas: el usuario necesita feedback cuando JS toma el control
  • Publica la base primero: si no puede desplegarse sola, no tienes una base

Common Mistakes

  • Formularios solo-JavaScript: el formulario no tiene atributo action y depende enteramente de JS. Sin JS, el formulario no hace nada. Corrección: dale a cada formulario un action y method reales.
  • Sin validación en el servidor: confiar en la validación del cliente. Salta el JS y envía datos inválidos directamente al servidor. Corrección: valida en el servidor siempre; trata las comprobaciones del cliente como pura UX.
  • Reemplazar en vez de mejorar: JS elimina la tabla HTML y la reconstruye como componente React. Sin JS, no hay tabla. Corrección: conserva el marcado renderizado y adjúntale comportamiento.
  • No probar sin JS: el sitio funciona en desarrollo (JS siempre carga) pero se rompe para usuarios con JS deshabilitado o conexiones lentas. Corrección: añade una pasada sin JS al QA manual y a CI.
  • CSS dependiente de JS: estilos que solo funcionan cuando JS añade clases. La versión sin JS se ve rota aunque funcione. Corrección: limita los estilos solo-JS bajo una clase .js en <html>.
  • Sobre-mejorar la base: la versión mejorada añade funciones que la base nunca tuvo, así que los usuarios sin JS reciben un producto distinto. Corrección: mantén la lista de tareas idéntica entre capas; solo debe cambiar la calidad de la interacción.

Further Reading

Preguntas frecuentes

¿Qué es la mejora progresiva?

Una estrategia de entrega por capas: HTML aporta la base funcional, CSS añade la presentación y JavaScript añade interactividad encima. Cada capa se apoya en la inferior, así que un fallo en una capa alta degrada hacia una página que funciona en lugar de una pantalla en blanco.

¿En qué se diferencia de la degradación elegante?

La mejora progresiva empieza simple y añade funciones; la degradación elegante empieza con todas las funciones y parchea los caminos de fallo después. En la práctica, el fallback a la base es mucho más probable que exista de verdad cuando es el punto de partida y no una idea tardía.

¿Debería usarla cualquier sitio?

Los sitios públicos deberían, porque el SEO, la accesibilidad y las condiciones impredecibles del usuario premian una base que funciona. Las herramientas internas y las aplicaciones genuinamente interactivas puede que no la necesiten si controlas el entorno.

¿Puedo usar mejora progresiva con React o Next.js?

Sí, mediante renderizado en servidor o generación estática más hidratación selectiva: el servidor envía HTML completo y el cliente adjunta comportamiento donde hace falta. Lo que rompe el patrón es el renderizado solo-cliente, donde el HTML llega vacío.

¿La mejora progresiva significa no usar JavaScript?

No. JavaScript es una capa de mejora, no un requisito. Sigues usándolo para validación en línea, envío asíncrono, animaciones e interactividad, pero la página sigue siendo usable sin él.

¿Por qué importa para el SEO?

Los crawlers indexan el HTML que reciben, y una base que funciona garantiza contenido completo sin ejecutar JavaScript. Además mejora los Core Web Vitals, porque el contenido se renderiza sin esperar a los bundles.

¿Cómo pruebo una página sin JavaScript?

En Chrome DevTools, abre la paleta de comandos y ejecuta "Disable JavaScript"; después recarga y verifica que los formularios se envían, los enlaces navegan y el contenido se lee. También puedes bloquear los archivos de script en el panel Network para simular un fallo parcial.

¿Puedo detectar características con una librería en vez de chequeos manuales?

Sí. Modernizr automatiza la detección de características y añade clases de capacidad al <html>, lo que combina bien con la técnica de CSS limitado por .js de arriba. Para un puñado de comprobaciones, los tests con el operador in suelen ser suficientes.