Progressive Web Apps: experiencias web con soporte offline
Una guía práctica para crear progressive web apps con service workers, estrategias de caché e instalabilidad que funcionan de forma fiable sin conexión.

Las Progressive Web Apps no son un framework ni una biblioteca. Son un conjunto de capacidades —soporte offline, instalabilidad, sincronización en segundo plano— que hacen que las aplicaciones web se comporten como aplicaciones nativas. La tecnología lleva años siendo estable, pero su adopción sigue siendo baja porque los desarrolladores tratan las PWA como un compromiso de todo o nada en lugar de una mejora incremental.
No hace falta adoptar un enfoque totalmente offline-first. Con solo añadir un service worker que almacene en caché el shell de tu aplicación y maneje con elegancia los fallos de red ya te sitúas por delante del 90% de las aplicaciones web.
El ciclo de vida del service worker
Un service worker es un archivo JavaScript que se ejecuta en un hilo independiente e intercepta las peticiones de red entre tu aplicación y el servidor. Entender su ciclo de vida evita los errores más comunes en las PWA.
// sw.js
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/offline.html',
];
// Install: pre-cache critical assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
})
);
self.clients.claim();
});El evento install se dispara una sola vez, cuando el service worker se registra por primera vez. El evento activate se dispara después de que se elimina el service worker anterior. Entre ambos eventos, el nuevo worker queda a la espera, por eso a veces los usuarios ven contenido desactualizado hasta que cierran todas las pestañas.
skipWaiting() y clients.claim() fuerzan la activación inmediata. Úsalos para actualizaciones de caché que no rompan nada. Evítalos cuando la estructura de la caché cambie de forma significativa, ya que podrías servir un nuevo shell HTML con referencias a recursos antiguos que quedaron en caché.
Estrategias de caché
Distintos recursos necesitan distintas estrategias de caché. Los recursos estáticos casi no cambian. Las respuestas de la API cambian constantemente. Aplicar la estrategia equivocada a cualquiera de los dos genera problemas.
Cache first (recursos estáticos)
// Best for: CSS, JS bundles, images, fonts
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'style' ||
event.request.destination === 'script' ||
event.request.destination === 'image') {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
});
})
);
}
});Network first (datos de API)
// Best for: API responses, user-specific data
async function networkFirst(request) {
const cache = await caches.open('api-cache');
try {
const networkResponse = await fetch(request);
// Only cache successful responses
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await cache.match(request);
if (cachedResponse) return cachedResponse;
// Return a meaningful offline response
return new Response(
JSON.stringify({ error: 'offline', cached: false }),
{ headers: { 'Content-Type': 'application/json' } }
);
}
}Stale-while-revalidate (contenido semidinámico)
// Best for: blog posts, product listings, non-critical API data
async function staleWhileRevalidate(request) {
const cache = await caches.open('content-cache');
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
});
// Return cached immediately, update in background
return cachedResponse || fetchPromise;
}Suele ser la mejor estrategia por defecto: los usuarios obtienen respuestas instantáneas desde la caché mientras el service worker actualiza en silencio la versión almacenada para la próxima vez.
El manifest de la aplicación web
El manifest es lo que hace que tu aplicación se pueda instalar. Sin él, los navegadores no mostrarán el aviso de «Añadir a la pantalla de inicio».
{
"name": "My Application",
"short_name": "MyApp",
"description": "A fast, offline-capable web application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a1a2e",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}El modo display: "standalone" elimina la interfaz del navegador y hace que la aplicación se vea nativa. Incluye tanto los iconos normales como los maskable: los iconos adaptativos de Android necesitan la variante maskable para renderizarse correctamente.
Cómo gestionar el estado offline
La peor experiencia offline es no tener ninguna experiencia: una página en blanco o un error del navegador. La segunda peor es aparentar que todo funciona mientras se descartan en silencio las acciones del usuario.
// ❌ Ignores network state — user thinks action succeeded
async function submitForm(data: FormData) {
await fetch('/api/submit', { method: 'POST', body: data });
showSuccess('Submitted!');
}
// ✅ Queues offline actions and provides honest feedback
async function submitForm(data: FormData) {
if (!navigator.onLine) {
await saveToOutbox(data);
showInfo('Saved offline. Will submit when connection returns.');
return;
}
try {
await fetch('/api/submit', { method: 'POST', body: data });
showSuccess('Submitted!');
} catch {
await saveToOutbox(data);
showInfo('Network error. Queued for retry.');
}
}IndexedDB ofrece almacenamiento offline fiable para las acciones en cola:
async function saveToOutbox(data: FormData) {
const db = await openDB('app-db', 1, {
upgrade(db) {
db.createObjectStore('outbox', {
keyPath: 'id',
autoIncrement: true,
});
},
});
const serialized = Object.fromEntries(data.entries());
await db.add('outbox', {
url: '/api/submit',
body: serialized,
timestamp: Date.now(),
});
}Background Sync
La API de Background Sync permite que tu service worker reintente las peticiones fallidas cuando vuelve la conectividad, incluso si el usuario ya cerró la pestaña.
// In your app code — register a sync
async function requestBackgroundSync() {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('outbox-sync');
}
// In sw.js — handle the sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'outbox-sync') {
event.waitUntil(processOutbox());
}
});
async function processOutbox() {
const db = await openDB('app-db', 1);
const items = await db.getAll('outbox');
for (const item of items) {
try {
await fetch(item.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.body),
});
await db.delete('outbox', item.id);
} catch {
// Will retry on next sync event
break;
}
}
}Background Sync cuenta con buen soporte en los navegadores basados en Chromium. Para Safari y Firefox, recurre a comprobar la conectividad al cargar la página y procesar el outbox en ese momento.
Puntos clave
- Empieza con un service worker básico: almacenar en caché el shell de la aplicación y manejar el estado offline con elegancia cubre la mayoría de los casos de uso
- Ajusta la estrategia de caché al tipo de contenido: cache-first para recursos estáticos, network-first para datos de API y stale-while-revalidate para el punto intermedio
- Versiona tus cachés: eliminar las cachés antiguas en el evento
activateevita servir recursos desactualizados - Sé honesto sobre el estado offline: pon en cola las acciones e informa a los usuarios de lo ocurrido en lugar de fallar en silencio
- Background Sync completa los flujos offline: deja que el service worker reintente cuando vuelva la conectividad


