
Una aplicación web progresiva (PWA) es una aplicación web que utiliza capacidades modernas del navegador para ofrecer una experiencia similar a la de una aplicación. Las PWA son confiables, rápidas y atractivas: combinan el alcance de la web con las capacidades de las aplicaciones móviles nativas.
Una aplicación web progresiva (PWA) es una aplicación web que utiliza capacidades modernas del navegador para ofrecer una experiencia similar a la de una aplicación. Las PWA son confiables, rápidas y atractivas: combinan el alcance de la web con las capacidades de las aplicaciones móviles nativas.
Las PWA resuelven una tensión fundamental: los usuarios quieren la disponibilidad instantánea de la web (sin instalación, vinculable, siempre actualizada) pero esperan el rendimiento y las características de las aplicaciones nativas (soporte sin conexión, notificaciones automáticas, presencia en la pantalla de inicio). Las PWA ofrecen ambas cosas.
El término "aplicación web progresiva" fue acuñado por Alex Russell y Frances Berriman de Google en 2015. Una PWA debe cumplir estas 10 características:
| # | Principio | Por qué es importante |
|---|---|---|
| 1 | Progresivo: funciona para todos | Degradación elegante en navegadores más antiguos |
| 2 | Responsivo: se adapta a cualquier pantalla | Escritorio, tableta, móvil |
| 3 | Independiente de la conectividad: funciona sin conexión | Almacenamiento en caché del trabajador del servicio |
| 4 | Parecido a una aplicación: sensación de aplicación nativa | Arquitectura de Shell, navegación fluida |
| 5 | Fresco: siempre actualizado | Ciclo de vida de actualización del trabajador del servicio |
| 6 | Seguro: servido a través de HTTPS | Previene la manipulación, necesaria para los trabajadores de servicios. |
| 7 | Descubrible: compatible con SEO | Los motores de búsqueda indexan las PWA |
| 8 | Reactivable: notificaciones automáticas | Vuelva a atraer a los usuarios como si fueran aplicaciones nativas |
| 9 | Instalable — Agregar a la pantalla de inicio | Manifiesto de la aplicación web |
| 10 | Enlazable: compartir mediante URL | No se requiere tienda de aplicaciones |
Un trabajador de servicio es un archivo JavaScript que se ejecuta en segundo plano, separado de la página web. Actúa como un proxy de red programable, interceptando solicitudes y habilitando funcionalidad fuera de línea, notificaciones automáticas y sincronización en segundo plano.
Installing → Installed → Activating → Activated → Idle → Terminated
│ │
└─── (new version detected) ──────────────┘
└─── Waiting (until all tabs close) ─────┘
// service-worker.js
const CACHE_NAME = 'my-pwa-v2';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html',
'/icons/icon-192.png'
];
// Install event — cache static assets
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
console.log('Caching app shell');
return cache.addAll(ASSETS_TO_CACHE);
})
);
// Force new service worker to activate immediately
self.skipWaiting();
});
// Activate event — clean old caches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
// Take control of all open pages immediately
clients.claim();
});
// Fetch event — intercept network requests
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
// Return cached response if available
if (cachedResponse) {
return cachedResponse;
}
// Otherwise fetch from network
return fetch(event.request).then(networkResponse => {
// Don't cache non-GET or error responses
if (!event.request.url.includes('/api/')) {
return networkResponse;
}
// Cache API responses for offline use
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseClone);
});
return networkResponse;
}).catch(() => {
// Network failed — return offline fallback
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
return new Response('Offline', { status: 503 });
});
})
);
});
| Estrategia | Descripción | Caso de uso |
|---|---|---|
| Caché primero | Verificar caché, recurrir a la red | Activos estáticos (CSS, JS, imágenes) |
| Red primero | Pruebe la red, recurra al caché | Llamadas API, contenido dinámico. |
| Obsoleto mientras se revalida | Devolver caché, actualizar en segundo plano | Feeds de noticias, publicaciones de blogs |
| Solo red | Obtener siempre de la red | Datos sensibles (bancarios) |
| Solo caché | Nunca recuperar de la red | shell de la aplicación |
// Stale-while-revalidate strategy
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
}).catch(() => cachedResponse);
return cachedResponse || fetchPromise;
})
);
});
El manifiesto es un archivo JSON que controla cómo aparece la PWA cuando se instala:
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "An amazing PWA that works offline",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3367D6",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["productivity", "utilities"],
"lang": "en-US",
"dir": "ltr",
"prefer_related_applications": false
}
Propiedades manifiestas explicadas:
display: standalone: se abre sin la interfaz de usuario del navegador (barra de direcciones, pestañas).display: fullscreen: pantalla completa, sin navegador Chrome.display: minimal-ui: controles mínimos del navegador.scope: "/": qué URL forman parte de la PWA.start_url: la página que se abre cuando se inicia la PWA.Los trabajadores de servicios necesitan un contexto seguro. HTTPS es obligatorio para:
# Redirect HTTP to HTTPS
# Nginx
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
El shell de la aplicación es el HTML, CSS y JavaScript mínimo necesarios para representar la interfaz de usuario. Se almacena en caché en la primera carga y sirve como base de la PWA.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#3367D6">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icons/icon-192.png" sizes="192x192">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<title>My PWA</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header id="app-header">My PWA</header>
<main id="app-content"></main>
<footer id="app-footer">© 2026</footer>
<!-- Register service worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.error('SW registration failed:', err));
});
}
</script>
<script src="/app.js"></script>
</body>
</html>
// app.js — Fetch content with offline support
const CACHE_KEY = 'content-cache';
async function loadContent(url) {
try {
const response = await fetch(url);
const data = await response.json();
// Cache for offline
const cache = await caches.open(CACHE_KEY);
cache.put(url, new Response(JSON.stringify(data)));
renderContent(data);
} catch (error) {
// Offline — load from cache
const cached = await caches.match(url);
if (cached) {
const data = await cached.json();
renderContent(data);
showOfflineIndicator();
} else {
showOfflineMessage();
}
}
}
// Request permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
// Get push subscription
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription)
});
}
}
// service-worker.js — Handle push events
self.addEventListener('push', event => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
vibrate: [100, 50, 100],
data: { url: data.url },
actions: [
{ action: 'open', title: 'Open' },
{ action: 'close', title: 'Dismiss' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
if (event.action === 'open' || !event.action) {
clients.openWindow(event.notification.data.url);
}
});
| Característica | PWA | Aplicación nativa |
|---|---|---|
| Distribución | URL (no se requiere tienda) | Tienda de aplicaciones, Google Play |
| Instalar fricción | Un toque (mensaje del navegador) | Descargar, instalar, permisos |
| Tamaño de archivo | KB a MB | 10 MB a 500 MB+ |
| Actualizaciones | Instantáneo (trabajador de servicio) | A través de la revisión de la tienda de aplicaciones |
| Sin conexión | Sí (trabajador de servicios) | Sí (integrado) |
| Notificaciones push | si | si |
| API de dispositivo | Cámara, GPS, acelerómetro, pago | Acceso completo a todas las API del dispositivo |
| Bluetooth | Web Bluetooth (limitado) | API Bluetooth completa |
| Tareas en segundo plano | Sincronización en segundo plano (limitada) | Ejecución completa en segundo plano |
| Rendimiento | Casi nativo (JavaScript/WebAssembly) | Nativo (código compilado) |
| SEO | Indexable por los motores de búsqueda | No indexable |
| Costo de desarrollo | Base de código única | iOS + Android (o multiplataforma) |
| Retención de usuarios | Más bajo (más fácil de salir) | Superior (instalado = comprometido) |
| Métrica | Antes de la PWA | Después de la PWA | Mejora |
|---|---|---|---|
| Primera carga | 5s | 1,2s | 76% más rápido |
| Cargas posteriores | 3s | 0,3 s | 90% más rápido |
| Tasa de rebote | 45% | 20% | 55% de reducción |
| Tasa de conversión | 2% | 3.5% | 75% de aumento |
| Páginas por sesión | 3 | 5 | 66% de aumento |
Resultados del mundo real:
| Empresa | Métrica clave | Mejora |
|---|---|---|
| Twitter (X) Lite | Tweets enviados por sesión | 75% de aumento |
| tiempo invertido | 40% de aumento | |
| Úber | Finalización de la reserva | Suave incluso en 2G |
| Starbucks | Usuarios activos diarios | aumento 2x |
| Alibaba | Tasa de conversión | 76% de aumento |
# Run Lighthouse from CLI
npx lighthouse https://example.com --view --preset=desktop
# PWA-specific checks:
# - ✅ Registers a service worker
# - ✅ Responds with 200 when offline
# - ✅ Web app manifest exists
# - ✅ Manifest has display: standalone
# - ✅ Served over HTTPS
# - ✅ Redirects HTTP to HTTPS
# - ✅ Configurable start URL
# - ✅ Icons provided (192px and 512px)
## PWA Pre-Flight Checklist
- [ ] HTTPS enabled
- [ ] Service worker registered and active
- [ ] Pages load offline (test with airplane mode)
- [ ] Manifest configured with correct icons
- [ ] "Add to Home Screen" prompt appears
- [ ] PWA opens in standalone mode with no address bar
- [ ] Splash screen displays correctly
- [ ] Push notifications work
- [ ] All pages are responsive (mobile, tablet, desktop)
- [ ] Performance budget met (Lighthouse score > 90)
- [ ] Cross-browser tested (Chrome, Firefox, Safari, Edge)
- [ ] Accessibility audit passes
// Register a sync event
async function scheduleSync() {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-pending-data');
}
// Service worker handles sync
self.addEventListener('sync', event => {
if (event.tag === 'sync-pending-data') {
event.waitUntil(syncPendingData());
}
});
// Request periodic sync (requires user engagement)
const registration = await navigator.serviceWorker.ready;
await registration.periodicSync.register('update-content', {
minInterval: 24 * 60 * 60 * 1000 // Once per day
});
// Share content using native share sheet
async function shareContent(title, text, url) {
if (navigator.share) {
await navigator.share({ title, text, url });
} else {
// Fallback to custom share UI
copyToClipboard(url);
}
}
// Set app icon badge
if (navigator.setAppBadge) {
await navigator.setAppBadge(5); // Show "5" on icon
} else if (navigator.clearAppBadge) {
await navigator.clearAppBadge();
}
| trampa | problema | Solución |
|---|---|---|
| Sobrealmacenamiento en caché | La aplicación ofrece contenido obsoleto | Cachés de versión, implementar caché primero para shell, red primero para contenido |
| Sin respaldo sin conexión | Pantalla blanca sin conexión | Siempre almacene en caché /offline.html con un mensaje significativo |
| Errores en la destrucción de caché | El viejo trabajador de servicios persiste | Utilice skipWaiting() y client.claim() |
| Problemas con la pantalla de presentación | Icono o color de tema incorrecto | Configure el manifiesto correctamente, use íconos enmascarables |
| Compatibilidad con safaris | Algunas funciones son limitadas (push, sincronización periódica) | Mejora progresiva: degradar con gracia |
| Tamaño de caché grande | Se superó la cuota de almacenamiento (50 MB por origen) | Eliminar cachés antiguos y limitar el tamaño de la caché |
| Falta la URL de inicio | La pantalla de inicio abre una página incorrecta | Especifique siempre start_url en el manifiesto |
Las aplicaciones web progresivas representan lo mejor de ambos mundos: el alcance y la accesibilidad de la web combinados con las capacidades y la experiencia del usuario de las aplicaciones nativas.
Las PWA no reemplazan a las aplicaciones nativas en todos los escenarios, pero para la mayoría de las aplicaciones de utilidad y basadas en contenido, brindan una experiencia de usuario superior con una fracción del costo de desarrollo.
Todavía no hay comentarios aprobados. Las respuestas nuevas pueden esperar moderación.