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ística | PWA | App nativa | App web |
|---|---|---|---|
| Instalación | Prompt del navegador | App Store | No instalable |
| Sin conexión | ✅ (Service Worker) | ✅ | ❌ |
| Notificaciones push | ✅ | ✅ | ❌ |
| Acceso a hardware | Limitado | Completo | Muy limitado |
| App Store | Opcional | Obligatorio | No necesario |
| Desarrollo | Una base de código | Por plataforma | Una base de código |
| Actualización | Automática (navegador) | Revisión en App Store | Automática |
| Costos | Gratis | 99$/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í:
- 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. - 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. - 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:
localhosten desarrollo) - La ruta del Service Worker determina su scope —
/sw.jscontrola 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
| Estrategia | Velocidad | Actualidad | Offline | Uso |
|---|---|---|---|---|
| Cache-First | Muy rápida | Desactualizada hasta actualización | ✅ | Assets estáticos |
| Network-First | Lenta (red) | Siempre actualizada | ✅ (caché anterior) | Contenido dinámico |
| Stale-While-Revalidate | Muy rápida | Algo desactualizada | ✅ | Imá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
pushen 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., v1 → v2), 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.


