Skip to content
IRC-CodingIRC-Coding
pwaservice-workerweb-apps-responsivasofflineweb-manifestdesarrollo-apps

PWA y Service Worker: Desarrollo de Web Apps Responsivas

Crea Progressive Web Apps con Service Worker, funcionalidad offline y diseño responsive. Tutorial práctico con ejemplos de código.

I

IRC-Coding Team

13 min read
PWA y Service Worker: Desarrollo de Web Apps Responsivas

Desarrollo de Apps: Web Responsivas, PWA y Service Worker

Las Progressive Web Apps (PWA) combinan lo mejor de las aplicaciones web y nativas: se ejecutan en el navegador, son instalables, funcionan sin conexión y envían notificaciones push. Ofrecen el alcance de la web con la experiencia de usuario de una app nativa, sin procesos de aprobación en tiendas, sin comisiones del 30% ni bases de código específicas de plataforma.

En este tutorial aprenderás a construir una PWA, desde el manifest hasta los Service Worker, datos offline y notificaciones push.

TL;DR — PWA en 90 segundos

Una PWA es una app web que se puede instalar, funciona sin conexión y se comporta como una app nativa.

---

Los 3 pilares: Web App Manifest (capacidad de instalación), Service Worker (offline y caché), Diseño responsivo (todos los dispositivos).

Service Worker: JavaScript que se ejecuta en segundo plano e intercepta las solicitudes de red para responderlas desde el caché.

Estrategias de caché: Cache-First (estáticas), Network-First (dinámicas), Stale-While-Revalidate (ambas).

Fin de la explicación compacta.

¿Qué es una Progressive Web App?

Una PWA es una aplicación web que:

  • Es instalable en escritorio y dispositivos móviles, aparece en el lanzador de aplicaciones o pantalla de inicio.
  • Funciona sin conexión gracias a que el Service Worker cachea recursos y datos.
  • Puede recibir notificaciones push incluso cuando la app está cerrada.
  • Es responsiva y se adapta a cualquier dispositivo: móvil, tablet, escritorio.
  • Es segura porque HTTPS es obligatorio para los Service Worker.
  • Ofrece una experiencia similar a apps nativas con pantalla completa, splash screen y sin interfaz del navegador.

PWA vs. App nativa vs. App web

CaracterísticaPWAApp nativaApp web
InstalaciónPrompt del navegadorApp StoreNo instalable
Sin conexión✅ (Service Worker)
Notificaciones push
Acceso a hardwareLimitadoCompletoMuy limitado
App StoreOpcionalObligatorioNo necesario
DesarrolloUna base de códigoPor plataformaUna base de código
ActualizaciónAutomática (navegador)Revisión en App StoreAutomática
CostosGratis99$/año (Apple), 25$ (Google)Gratis

Los tres pilares de una PWA

1. Web App Manifest

El manifest es un archivo JSON que define cómo se instala la app y cómo se ve. Sin manifest, no hay instalación.

{
  "name": "Mi PWA",
  "short_name": "PWA",
  "description": "Una Progressive Web App con funcionalidad sin conexión",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "shortcuts": [
    {
      "name": "Nueva nota",
      "url": "/new",
      "icons": [{ "src": "/icons/shortcut.png", "sizes": "96x96" }]
    }
  ]
}

Campos principales explicados:

  • display: "standalone" — La app se ejecuta sin la interfaz del navegador (barra de direcciones, navegación).
  • theme_color — Color de la barra de estado en dispositivos móviles.
  • purpose: "maskable" — El icono puede ajustarse a diferentes formas en Android.
  • shortcuts — Acciones rápidas en el lanzador de apps (clic derecho en escritorio, presión larga en móvil).

Inclusión en el HTML:

<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#000000" />
<!-- Específico de iOS: -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />

¿Por qué las etiquetas meta específicas de iOS? Safari ignora parcialmente el manifest. Las etiquetas apple-* garantizan que la app se instale correctamente en iOS y se presente adecuadamente.

2. Service Worker — El corazón

El Service Worker es un JavaScript Worker que se ejecuta en segundo plano, independientemente de la página web. Actúa como intermediario entre la app y la red: cada solicitud pasa por el Service Worker, que decide si responder desde el caché o desde la red.

Ciclo de vida del Service Worker:

Instalar → Activar → Eventos Fetch/Message (continuo)
   ↓         ↓
Caché      Eliminar cachés antiguos
// sw.js — El Service Worker

const CACHE_NAME = 'my-pwa-v1';
const ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icons/icon-192.png',
  '/offline.html'  // Página de fallback
];

// INSTALACIÓN: Cachear assets con anticipación (Pre-Caching)
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(ASSETS))
      .then(() => self.skipWaiting())  // Activar inmediatamente
  );
});

// ACTIVACIÓN: Eliminar cachés antiguos (Versionado)
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys => {
      return Promise.all(
        keys
          .filter(key => key !== CACHE_NAME)  // Todo excepto el caché actual
          .map(key => caches.delete(key))      // Eliminar
      );
    }).then(() => self.clients.claim())  // Tomar el control inmediatamente
  );
});

// FETCH: Estrategia Cache-First (para assets estáticos)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        if (response) {
          return response;  // Desde caché
        }
        // No está en caché → cargar desde la red
        return fetch(event.request).catch(() => {
          // Sin red → fallback offline
          if (event.request.mode === 'navigate') {
            return caches.match('/offline.html');
          }
        });
      })
  );
});

Lo que sucede aquí:

  1. Instalar: En la primera visita, todos los assets estáticos (HTML, CSS, JS, iconos) se almacenan en caché. skipWaiting() hace que el Service Worker se active inmediatamente.
  2. Activar: Cuando se implementa una nueva versión del Service Worker (CACHE_NAME = 'my-pwa-v2'), el nuevo Service Worker elimina todos los cachés antiguos.
  3. Fetch: Cada solicitud se busca primero en el caché. Encontrada → responder desde caché (rápido, sin conexión). No encontrada → cargar desde la red. Sin red → mostrar la página offline.

3. Diseño responsivo

El diseño responsivo no es específico de PWA, pero es fundamental: una PWA debe verse bien en móvil, tablet y escritorio.

/* Mobile-First: Estilos estándar para móvil */
.container {
  width: 100%;
  padding: 1rem;
  font-size: 16px;
}

/* Navegación: Hamburguesa en móvil, horizontal en escritorio */
.nav {
  display: flex;
  flex-direction: column;  /* Móvil: vertical */
}

/* Tablet (768px+) */
@media (min-width: 768px) {
  .container {
    max-width: 720px;
    margin: 0 auto;
  }
  .nav {
    flex-direction: row;  /* Tablet: horizontal */
  }
}

/* Escritorio (1024px+) */
@media (min-width: 1024px) {
  .container {
    max-width: 960px;
  }
}

/* Escritorio grande (1440px+) */
@media (min-width: 1440px) {
  .container {
    max-width: 1200px;
  }
}

Principio Mobile-First: Comienza con estilos para móvil (pantallas más pequeñas) y luego añade media queries con min-width para pantallas más grandes. Esto es más eficiente que un enfoque Desktop-First con max-width, porque los dispositivos móviles procesan menos CSS.

Registrar el Service Worker

El Service Worker debe registrarse desde la aplicación principal:

// app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then(registration => {
        console.log('SW registriert:', registration.scope);
        
        // Update-Erkennung
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              // Neue Version verfügbar — User benachrichtigen
              showUpdateNotification();
            }
          });
        });
      })
      .catch(error => {
        console.error('SW Registrierung fehlgeschlagen:', error);
      });
  });
}

// Update anwenden
function applyUpdate() {
  navigator.serviceWorker.getRegistration().then(reg => {
    if (reg.waiting) {
      reg.waiting.postMessage({ type: 'SKIP_WAITING' });
    }
  });
  window.location.reload();
}

Puntos importantes:

  • Los Service Worker deben servirse por HTTPS (excepción: localhost en desarrollo)
  • La ruta del Service Worker determina su scope — /sw.js controla todo el dominio
  • Los Service Worker se instalan en la primera visita y se cargan desde caché en las visitas posteriores

Estrategias de caché, ¿cuál para cada caso?

Elegir la estrategia de caché correcta es crucial para el rendimiento y la capacidad offline de tu PWA.

Cache-First (para assets estáticos)

Responde siempre desde el caché si está disponible. Solo si no está en caché, hace la petición a la red.

// Ideal para: CSS, JS, Fonts, Icons — archivos que casi nunca cambian
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cached => cached || fetch(event.request))
  );
});

Cuándo usarla: Assets estáticos que no cambian entre deployments. En un nuevo deployment cambias el nombre del caché y eliminas los antiguos.

Network-First (para contenido dinámico)

Intenta primero la red. Si falla (sin conexión), cae de vuelta al caché.

// Ideal para: API requests, feeds de noticias, datos de usuario
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then(response => {
        // Guardar la respuesta exitosa en caché
        const clone = response.clone();
        caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
        return response;
      })
      .catch(() => {
        // Sin conexión → desde caché
        return caches.match(event.request);
      })
  );
});

Cuándo usarla: Contenido dinámico que siempre debe estar actualizado, pero que puede ser legible offline con versiones anteriores.

Stale-While-Revalidate (lo mejor de ambos)

Responde inmediatamente desde el caché (rápido) y obtiene una nueva versión en segundo plano (actualizado).

// Ideal para: imágenes, fonts, assets no críticos
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      const fetchPromise = fetch(event.request).then(response => {
        const clone = response.clone();
        caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
        return response;
      }).catch(() => cached);
      return cached || fetchPromise;
    })
  );
});

Cuándo usarla: Assets que a veces cambian, pero donde una versión ligeramente desactualizada es aceptable. El usuario obtiene una respuesta inmediata y en la siguiente visita tendrá la versión actual.

Comparación de estrategias

EstrategiaVelocidadActualidadOfflineUso
Cache-FirstMuy rápidaDesactualizada hasta actualizaciónAssets estáticos
Network-FirstLenta (red)Siempre actualizada✅ (caché anterior)Contenido dinámico
Stale-While-RevalidateMuy rápidaAlgo desactualizadaImágenes, fonts

Enfoque multi-estrategia

En la práctica usas diferentes estrategias para diferentes tipos de peticiones:

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // API requests: Network-First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
    return;
  }
  
  // Imágenes: Stale-While-Revalidate
  if (event.request.destination === 'image') {
    event.respondWith(staleWhileRevalidate(event.request));
    return;
  }
  
  // Todo lo demás: Cache-First
  event.respondWith(cacheFirst(event.request));
});

Notificaciones push

Las notificaciones push permiten que tu PWA alcance usuarios incluso cuando la app está cerrada. Esto requiere un servicio de push como Firebase Cloud Messaging o Web Push.

// 1. Solicitar permiso
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('El usuario rechazó las notificaciones');
    return;
  }
  
  // 2. Crear suscripción a push
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,  // Las notificaciones deben ser visibles
    applicationServerKey: VAPID_PUBLIC_KEY  // Clave pública VAPID
  });
  
  // 3. Enviar suscripción al servidor
  await fetch('/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
  
  console.log('Suscripción push registrada');
}

// 4. Recibir en el Service Worker
self.addEventListener('push', (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url },
      actions: [
        { action: 'open', title: 'Öffnen' },
        { action: 'close', title: 'Schließen' }
      ]
    })
  );
});

// 5. Manejar clic en notificación
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  if (event.action === 'open' || !event.action) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

Lo que necesitas:

  • Claves VAPID (generables con npx web-push generate-vapid-keys)
  • Un servidor que envíe notificaciones push (por ejemplo con el package web-push)
  • HTTPS (obligatorio para Service Worker)

Limitación en iOS: Las notificaciones push en iOS funcionan desde iOS 16.4 y solo si la PWA se agregó a la pantalla de inicio.

Datos offline con IndexedDB

El caché es bueno para assets estáticos, pero no para datos estructurados. Para datos offline como Todos, notas o datos de usuario usas IndexedDB.

// IndexedDB es asincrónico y basado en callbacks — idb lo convierte a Promises
// npm install idb
import { openDB } from 'idb';

const db = await openDB('my-pwa-db', 1, {
  upgrade(db) {
    // Crear Object Store (como una tabla)
    const store = db.createObjectStore('todos', { keyPath: 'id' });
    // Índice para búsquedas más rápidas
    store.createIndex('by-status', 'status');
    store.createIndex('by-date', 'createdAt');
  }
});

// Guardar datos (offline)
async function saveTodo(todo) {
  await db.put('todos', {
    id: crypto.randomUUID(),
    title: todo.title,
    status: 'pending',
    createdAt: Date.now()
  });
}

// Leer datos
async function getTodos() {
  return await db.getAll('todos');
}

// Filtrar por estado
async function getPendingTodos() {
  return await db.getAllFromIndex('todos', 'by-status', 'pending');
}

// Eliminar datos
async function deleteTodo(id) {
  await db.delete('todos', id);
}

Caché vs. IndexedDB:

  • Cache API: Para respuestas HTTP (HTML, CSS, JS, imágenes). Clave = petición, valor = respuesta.
  • IndexedDB: Para datos estructurados (objetos JSON, datos de usuario, cambios offline). Clave = clave arbitraria, valor = objeto arbitrario.

Sincronización en Segundo Plano (Sincronizar Cambios Offline)

Cuando el usuario realiza cambios sin conexión, Background Sync puede sincronizarlos automáticamente en cuanto vuelva a estar en línea:

// En la app: registrar sincronización
async function syncTodos() {
  const reg = await navigator.serviceworker.ready;
  await reg.sync.register('sync-todos');
}

// En el Service Worker: manejar el evento de sincronización
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-todos') {
    event.waitUntil(syncTodosWithServer());
  }
});

async function syncTodosWithServer() {
  const pendingTodos = await db.getAllFromIndex('todos', 'by-status', 'pending');
  for (const todo of pendingTodos) {
    await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(todo)
    });
    await db.put('todos', { ...todo, status: 'synced' });
  }
}

PWA con Frameworks

Vite PWA Plugin (recomendado para proyectos Vite)

// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',  // Actualizaciones automáticas
      manifest: {
        name: 'Meine PWA',
        short_name: 'PWA',
        theme_color: '#000000',
        icons: [
          { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
        ]
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 50, maxAgeSeconds: 3600 }
            }
          }
        ]
      }
    })
  ]
};

Ventaja: Vite PWA genera el Service Worker automáticamente con Workbox. No necesitas escribir manualmente un Service Worker.

Next.js PWA

npx create-next-app@latest my-pwa --typescript
npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
});

module.exports = withPWA({});

Astro PWA

Astro tiene una integración PWA oficial:

npx astro add astro-pwa

Probar PWA

Auditoría de Lighthouse

Lighthouse verifica la conformidad con los estándares PWA y proporciona una puntuación:

npx lighthouse https://localhost:3000 --view --preset=pwa

Lighthouse verifica:

  • Manifest presente y correcto
  • Service Worker registrado
  • HTTPS activo
  • Responsivo en dispositivos móviles
  • Funcionalidad offline
  • Instalabilidad

Depuración del Service Worker

Chrome DevTools → Application → Service Workers:

  • Mostrar estado (activo, en espera, instalado)
  • “Update on reload” para desarrollo
  • “Bypass for network” para desactivar el Service Worker
  • “Unregister” para eliminar el Service Worker

Prueba Manual de Offline

Chrome DevTools → Application → Service Workers → activar casilla “Offline”. Luego recarga la página y verifica si el modo offline funciona.

Detectar la Instalación de la App

Puedes interceptar el prompt de instalación y mostrar tu propio botón “Instalar”:

let deferredPrompt;

// El navegador muestra beforeinstallprompt (antes del prompt automático)
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();  // Prevenir el prompt automático
  deferredPrompt = e;
  showInstallButton();  // Mostrar botón personalizado
});

// El usuario hace clic en el botón Instalar
installButton.addEventListener('click', async () => {
  if (!deferredPrompt) return;
  
  deferredPrompt.prompt();  // Mostrar diálogo de instalación
  const { outcome } = await deferredPrompt.userChoice;
  
  if (outcome === 'accepted') {
    console.log('El usuario ha instalado la app');
    hideInstallButton();
  } else {
    console.log('El usuario rechazó la instalación');
  }
  
  deferredPrompt = null;  // El prompt solo se puede usar una vez
});

// La app fue instalada
window.addEventListener('appinstalled', () => {
  console.log('App instalada correctamente');
  // Enviar evento de analytics
});

Importante: beforeinstallprompt no se dispara en iOS. Los usuarios de iOS deben seleccionar manualmente “Añadir a la pantalla de inicio”.

Puntos Relevantes para el Examen

  • PWA: Progressive Web App = instalable + offline + responsiva + segura (HTTPS)
  • 3 pilares: Web App Manifest (instalación), Service Worker (offline/caché), Responsive Design (todos los dispositivos)
  • Manifest: archivo JSON con name, icons, display, theme_color, start_url
  • Ciclo de vida del Service Worker: Install (precargar) → Activate (eliminar cachés antiguos) → Fetch (interceptar solicitudes)
  • Estrategias de caché: Cache-First (estático), Network-First (dinámico), Stale-While-Revalidate (ambos)
  • Push Notifications: claves VAPID, Push-Subscription, evento push en el Service Worker
  • IndexedDB: base de datos offline para datos estructurados (a diferencia de Cache API para respuestas HTTP)
  • Background Sync: sincronización automática de cambios offline
  • HTTPS: obligatorio para Service Worker (excepción: localhost)
  • Testing: auditoría PWA de Lighthouse, pestaña Application de Chrome DevTools

FAQ

¿Son las PWA un sustituto de las apps nativas? En muchos casos sí, especialmente para aplicaciones de contenido, e-commerce y herramientas. Las limitaciones aparecen con acceso a hardware (Bluetooth, NFC, sensores), procesos en segundo plano y presencia en app stores. Para juegos y aplicaciones que requieren acceso cercano al hardware, las apps nativas siguen siendo la mejor opción.

¿Necesito un framework para PWA? No, las PWA funcionan con JavaScript vanilla. Un manifest y un Service Worker son suficientes. Sin embargo, los frameworks (Vite PWA, Next-PWA) simplifican la configuración y generan el Service Worker automáticamente con Workbox.

¿Funcionan las PWA en iOS? Sí, con limitaciones. Las Push Notifications están disponibles desde iOS 16.4. Background Sync no es compatible. El prompt de instalación debe hacerse manualmente a través de “Compartir → Añadir a la pantalla de inicio” (no hay evento beforeinstallprompt).

¿Cómo actualizo mi PWA? En cada deploy: 1) Subir nuevos assets, 2) aumentar el nombre del caché en el Service Worker (p. ej., v1v2), 3) el navegador detecta en la siguiente visita que el Service Worker ha cambiado e instala la nueva versión. Con skipWaiting() la nueva versión se activa inmediatamente.

¿Cuál es la diferencia entre Cache API e IndexedDB? Cache API almacena respuestas HTTP (HTML, CSS, JS, imágenes), siendo la clave la solicitud y el valor la respuesta. IndexedDB almacena datos estructurados (objetos JSON) con claves e índices arbitrarios. Usa Cache API para assets estáticos e IndexedDB para datos de usuario y cambios offline.

¿Puedo combinar PWA y SPA (React/Vue/Svelte)? Sí, es incluso la norma. La SPA se ejecuta como web app, el Service Worker cachea los assets de la SPA, y el manifest la hace instalable. El plugin Vite PWA soporta React, Vue, Svelte y otros frameworks fuera de la caja.

Lectura Recomendada

Keine Bücher für Kategorie "web-development" gefunden.

Volver al blog
Share:

Nächster Artikel in Desarrollo de Software

Weiterlesen
APIs y formatos de intercambio de datos explicados

Entradas relacionadas