27. PWA, offline y notificaciones push
Toda la teoría que has visto hasta ahora asume, sin decirlo, que la red funciona. Este capítulo elimina esa suposición. Vas a aprender cómo un service worker se interpone entre tu aplicación y la red, qué estrategias de caché existen y cuál conviene a cada recurso, cómo se almacenan datos en el cliente de forma seria, cómo se sincronizan las escrituras hechas sin conexión y cómo se envían notificaciones push desde NestJS. También aprenderás a reconocer los casos en los que nada de esto merece la pena.
27.1 Qué vas a poder hacer al terminar
- Explicar qué es exactamente un service worker, en qué hilo se ejecuta, cuál es su ciclo de vida completo y por qué ese ciclo de vida es la causa del 90 % de los problemas que la gente atribuye a «la caché».
- Elegir con criterio entre las cinco estrategias de caché clásicas para cada tipo de recurso de tu aplicación, y justificar la elección en términos de frescura, latencia y consumo de datos.
- Configurar el service worker oficial de Angular mediante
ngsw-config.json, entender qué hace realmente con tus ficheros y depurarlo cuando no se comporta como esperas. - Diseñar el manifiesto de la aplicación y controlar el flujo de instalación en el dispositivo del usuario.
- Comparar con criterio los cinco mecanismos de almacenamiento del navegador y saber cuál usar para cada dato, incluidas sus cuotas y sus modos de fallo.
- Implementar una cola de escrituras diferidas que permita trabajar sin conexión y sincronizar después, con detección y resolución de conflictos.
- Montar notificaciones push de extremo a extremo: claves VAPID, suscripción en Angular, almacenamiento en base de datos con MikroORM y envío desde un servicio de NestJS.
- Gestionar las actualizaciones de la aplicación sin dejar clientes antiguos hablando con una API nueva.
- Argumentar en una entrevista por qué una PWA no es «una web con un icono» y en qué escenarios sigue ganando una aplicación nativa.
27.2 El problema: la red no es fiable, y tu aplicación se comporta como si lo fuera
Detente un momento en el modelo mental que has ido construyendo a lo largo del libro. El usuario abre TaskFlow, Angular arranca, un componente pide sus tareas al servidor, el HttpClient emite una petición, NestJS la atiende, MikroORM consulta PostgreSQL y en unas decenas de milisegundos la lista aparece en pantalla. Es un modelo correcto y es el que gobierna la inmensa mayoría del código que escribimos. También es un modelo que se derrumba en cuanto el usuario entra en el metro.
El derrumbe no es elegante. Sin service worker, un navegador que pierde la conexión y recibe una petición de navegación muestra su propia página de error: la pantalla del dinosaurio en Chrome, un mensaje escueto en Safari. Todo tu trabajo de diseño, todos tus estados de carga cuidados, toda tu marca, desaparecen y son sustituidos por una pantalla del navegador que dice que no hay internet. Y no hablamos solo del metro: hablamos del ascensor, del sótano de un almacén donde un operario registra incidencias, de una obra, de un hospital con paredes gruesas, de un pueblo con cobertura irregular, de un avión, de una red corporativa saturada a las nueve de la mañana.
Hay además un problema más sutil que la desconexión total, y es peor precisamente porque es más sutil: la conexión mala. Una red que responde, pero tarda ocho segundos. Una red que abre la conexión TCP y luego se queda callada. Una red que funciona para la primera petición y falla para la tercera. Este escenario, que en la literatura se conoce como lie-fi, es hostil porque tu código no ve un error: ve una promesa que no se resuelve. El indicador de carga gira indefinidamente y el usuario, que no tiene forma de distinguir entre «está tardando» y «esto está roto», cierra la aplicación.
Cuando un equipo discute si merece la pena invertir en funcionamiento sin conexión, la discusión suele plantearse como «¿cuántos de nuestros usuarios están sin cobertura?». Es la pregunta equivocada, porque la respuesta es «casi ninguno, casi nunca» y la conclusión es no hacer nada. La pregunta correcta es «¿cuántas sesiones sufren al menos un fallo de red?», y ahí la respuesta cambia radicalmente, porque una sesión larga en móvil atraviesa muchos estados de red. Un usuario que nunca está oficialmente «sin conexión» puede sufrir tres peticiones fallidas al día. Lo que estamos diseñando no es tanto un modo desconectado como una tolerancia a fallos de red.
27.2.1 Qué aporta realmente resolver esto
Merece la pena separar los beneficios, porque se mezclan constantemente y no todos tienen el mismo peso ni el mismo coste de implementación.
| Beneficio | Qué significa en la práctica | Coste de conseguirlo |
|---|---|---|
| Arranque instantáneo | El armazón de la aplicación (HTML, JavaScript, CSS, fuentes) se sirve desde el disco local sin tocar la red. La segunda visita arranca en milisegundos aunque la red esté lenta. | Bajo. Es prácticamente gratis: una configuración y ya. |
| Página de respaldo sin conexión | En lugar del error del navegador, el usuario ve tu aplicación con un mensaje tuyo que explica la situación. | Bajo. |
| Lectura sin conexión | Los datos que el usuario ya vio siguen disponibles. Puede consultar sus tareas en el metro. | Medio. Requiere decidir qué se guarda, cuándo caduca y cómo se avisa de que el dato puede estar obsoleto. |
| Escritura sin conexión | El usuario crea o edita tareas sin red y los cambios se envían al recuperar la conexión. | Alto. Es donde aparecen los conflictos, los identificadores provisionales y la idempotencia. La mayor parte de este capítulo trata de esto. |
| Instalación en el dispositivo | Icono en la pantalla de inicio, ventana sin barra de direcciones, aparición en el conmutador de aplicaciones. | Bajo, pero con matices por plataforma. |
| Notificaciones push | Puedes reactivar al usuario cuando le asignan una tarea, aunque no tenga la aplicación abierta. | Medio-alto. Implica servidor, claves, permisos y una política de uso responsable. |
Fíjate en que los tres primeros beneficios son baratos y los tres últimos no. Un error de planificación muy común es aprobar el proyecto «PWA» pensando en el arranque instantáneo y descubrir a mitad de camino que lo que el negocio esperaba era la escritura sin conexión, que es un orden de magnitud más de trabajo.
27.3 Qué es una aplicación web progresiva, y qué no es
El término Progressive Web App lo acuñó en 2015 Alex Russell, ingeniero de Chrome, junto con la diseñadora Frances Berriman. No describe una tecnología concreta sino un conjunto de capacidades que una aplicación web puede adquirir de forma progresiva: cada navegador que soporte una pieza más ofrece una experiencia mejor, y los que no la soporten siguen viendo una web perfectamente funcional. Esa palabra, progresiva, es la clave y la que más se olvida. Una PWA bien construida no se rompe en un navegador que no tiene service workers; simplemente pierde la parte que dependía de ellos.
Antes de los service workers existió AppCache, un mecanismo declarativo basado en un fichero de manifiesto que listaba los recursos a cachear. Fue un fracaso tan rotundo que la comunidad escribió sobre él un artículo célebre titulado «Application Cache is a Douchebag». Sus problemas eran estructurales: el modelo era declarativo y no programable, así que si tu caso no encajaba en lo que los diseñadores del estándar habían previsto, no había salida; el fichero manifiesto se cacheaba a sí mismo, produciendo situaciones sin escapatoria; y actualizaba siempre con un ciclo de retraso, de modo que el usuario veía la versión anterior. Los service workers son la respuesta a ese fracaso, y su diseño se entiende mejor sabiéndolo: en lugar de un formato declarativo cerrado, la especificación ofrece un proxy programable y te deja a ti la política. Es más potente y también más fácil de estropear, porque ahora la responsabilidad de la corrección es tuya.
27.3.1 Los requisitos técnicos concretos
Para que un navegador considere tu aplicación instalable hacen falta, como mínimo, tres cosas. Los detalles exactos varían entre navegadores y entre versiones, así que conviene comprobarlos con las herramientas de desarrollo en lugar de fiarse de una lista aprendida de memoria.
- HTTPS obligatorio. Los service workers solo se registran en un contexto seguro. La única excepción es
localhost, que se considera seguro para permitir el desarrollo. La razón es contundente: un service worker es un intermediario que puede interceptar y reescribir todas las respuestas de tu origen y que persiste entre sesiones. Permitir eso sobre HTTP sin cifrar sería regalar una herramienta perfecta a cualquiera con acceso a la red del usuario, que podría inyectar un service worker malicioso permanente. - Un manifiesto de aplicación web válido, con nombre, iconos de los tamaños requeridos, una URL de inicio y un modo de visualización.
- Un service worker registrado con, al menos, un manejador del evento
fetch. Un service worker vacío no cuenta.
27.3.2 PWA frente a nativo frente a híbrido
Esta comparación aparece en casi todas las entrevistas de arquitectura frontend y merece una respuesta honesta y no partidista.
| Criterio | PWA | Híbrido (Capacitor, Ionic) | Nativo (Swift, Kotlin) |
|---|---|---|---|
| Base de código | Una sola, la misma que la web | Una, con envoltorio por plataforma | Una por plataforma |
| Distribución | Una URL. Sin revisión de tienda, sin comisiones, despliegue inmediato | Tiendas, con revisión y comisiones | Tiendas |
| Actualización | Al recargar. El usuario no hace nada | Requiere publicar una versión nueva, salvo actualizaciones parciales | Requiere publicar |
| Acceso al hardware | Cámara, geolocalización, sensores básicos, bluetooth y USB en algunos navegadores. Limitado y desigual | Prácticamente completo mediante complementos | Completo |
| Rendimiento gráfico | Bueno para interfaces de gestión, insuficiente para juegos exigentes | Similar a la PWA con puentes nativos donde importa | Máximo |
| Ejecución en segundo plano | Muy restringida | Amplia | Completa |
| Notificaciones push | Sí, con diferencias importantes por plataforma | Sí, plenamente | Sí, plenamente |
| Descubrimiento | Indexable por buscadores, enlazable | Solo dentro de la tienda | Solo dentro de la tienda |
| Coste de desarrollo | El más bajo si ya tienes web | Medio | El más alto |
Históricamente, el soporte de PWA en Safari e iOS ha ido por detrás del resto y con restricciones propias que han cambiado varias veces. Las notificaciones push en iOS llegaron muy tarde y con la condición de que la aplicación esté añadida a la pantalla de inicio. Las cuotas de almacenamiento y las políticas de expulsión de datos también han sido más agresivas. La consecuencia práctica para tu arquitectura es que nunca debes asumir que un dato guardado en el cliente sigue ahí: el servidor es la fuente de verdad y el almacenamiento local es una optimización que puede desaparecer. Como el detalle exacto de qué soporta cada versión cambia con cada actualización del sistema, comprueba el estado actual en la documentación de MDN y en caniuse antes de comprometerte con un cliente.
27.4 El service worker por dentro
Un service worker es un fichero de JavaScript que el navegador ejecuta fuera de la página, en su propio hilo, sin acceso al DOM y con un ciclo de vida independiente del de la pestaña. Una vez instalado, se sitúa entre la aplicación y la red y puede interceptar cada petición que salga de su ámbito para decidir qué hacer con ella: dejarla pasar, responderla desde una caché, sintetizar una respuesta desde cero o combinar varias de esas opciones.
Imagina que tu aplicación es un vecino que pide paquetes. Sin service worker, cada paquete viaja desde el almacén hasta la puerta del piso. Con service worker, hay un conserje en la portería que ve pasar todos los paquetes. El conserje puede tener una copia de algunos artículos guardada en un armario y entregarla al instante sin llamar al almacén; puede pedirlo al almacén y, de camino, quedarse una copia para la próxima; puede entregar la copia vieja mientras encarga la nueva; y si el almacén está cerrado, puede entregar lo que tenga o darte una nota explicando la situación. Tres detalles completan la analogía. Primero, el conserje trabaja aunque tú no estés en casa. Segundo, el conserje no puede entrar en tu piso a mover muebles: no tiene DOM. Y tercero, cuando contratas a un conserje nuevo, el antiguo no se marcha hasta que todos los vecinos que atendía han salido del edificio, que es exactamente el comportamiento que explica la sección siguiente.
27.4.1 Qué puede y qué no puede hacer
Puede
- Interceptar peticiones de red mediante el evento
fetch. - Leer y escribir en la Cache Storage y en IndexedDB.
- Recibir mensajes push del servidor y mostrar notificaciones estando la aplicación cerrada.
- Comunicarse con las páginas que controla mediante
postMessage. - Ejecutarse cuando ninguna pestaña está abierta, si el navegador lo despierta para un evento.
No puede
- Tocar el DOM. No tiene
documentniwindow. - Usar API síncronas como
localStorage. Solo API asíncronas. - Mantener estado en memoria de forma fiable: el navegador lo detiene cuando está ocioso y lo revive al llegar un evento, perdiendo las variables globales.
- Registrarse fuera de un contexto seguro.
- Controlar peticiones de fuera de su ámbito.
El punto de «no puede mantener estado en memoria» merece un aviso propio porque produce fallos que solo aparecen en producción. Es tentador escribir let contador = 0; en el ámbito superior del service worker y usarlo entre eventos. Funcionará en desarrollo, donde el service worker está constantemente activo porque tú lo estás usando. En producción, el navegador detendrá el proceso tras unos segundos de inactividad y lo arrancará de nuevo cuando llegue el siguiente evento, con las variables reinicializadas. Toda información que deba sobrevivir entre eventos va en IndexedDB o en la Cache Storage, sin excepción.
27.4.2 El ámbito, la trampa silenciosa
Un service worker solo controla las páginas que cuelgan de su ámbito, y el ámbito por defecto es el directorio desde el que se sirve el fichero. Un service worker servido en /js/sw.js controla únicamente /js/ y sus subdirectorios, lo que en la práctica significa que no controla nada útil. Por eso el fichero del service worker se sirve siempre desde la raíz del sitio.
<!-- El fichero vive en /assets/js/sw.js -->
<script>
navigator.serviceWorker.register('/assets/js/sw.js');
// Ámbito resultante: /assets/js/
// No controla /, ni /tareas, ni /proyectos.
// El registro «funciona» y no falla nada,
// simplemente el evento fetch nunca se dispara.
</script>
<script>
// El fichero se despliega en la raíz del sitio
navigator.serviceWorker.register('/sw.js');
// Ámbito resultante: /
// Controla toda la aplicación.
</script>
Si por razones de organización del despliegue no puedes servir el fichero desde la raíz, existe una salida: la cabecera Service-Worker-Allowed. Si el servidor responde al fichero del service worker con Service-Worker-Allowed: /, el navegador permite registrarlo con un ámbito más amplio que su ubicación. Es un mecanismo deliberadamente incómodo, porque ampliar el ámbito es una decisión de seguridad y el estándar quiere que sea el servidor, y no el JavaScript de la página, quien la autorice.
27.4.3 El ciclo de vida completo
Aquí está el corazón del capítulo. Casi todos los problemas que se atribuyen vagamente a «la caché del service worker» son en realidad malentendidos sobre este ciclo de vida. Léelo despacio.
La página llama a register('/sw.js')
│
▼
┌───────────────┐
│ DESCARGANDO │ El navegador descarga sw.js y lo compara byte a byte
│ │ con la copia que ya tenía. Si es idéntico, no pasa nada
└───────┬───────┘ más y el proceso termina aquí.
│ el fichero es distinto
▼
┌───────────────┐
│ INSTALANDO │ Se dispara el evento 'install'.
│ │ Momento típico para precachear el armazón.
└───────┬───────┘ Si event.waitUntil() rechaza → INSTALACIÓN FALLIDA
│ éxito
▼
┌───────────────┐
│ INSTALADO │ Listo, pero NO controla nada todavía.
│ (esperando) │ Espera a que TODAS las pestañas con la versión
└───────┬───────┘ anterior se cierren. ← AQUÍ SE ATASCA TODO EL MUNDO
│ ya no quedan clientes de la versión antigua
│ (o el nuevo SW llamó a skipWaiting())
▼
┌───────────────┐
│ ACTIVANDO │ Se dispara el evento 'activate'.
│ │ Momento típico para borrar cachés obsoletas.
└───────┬───────┘
│
▼
┌───────────────┐
│ ACTIVO │ Intercepta 'fetch' de los clientes que controla.
│ │ El navegador puede detenerlo y revivirlo libremente.
└───────────────┘
Hay tres puntos de este diagrama que conviene grabar a fuego, porque son los que producen las conversaciones de «pues a mí me funciona».
Primero: la comparación byte a byte. El navegador solo considera que hay una versión nueva si el contenido del fichero del service worker ha cambiado. Un solo byte distinto basta. Ninguno cambiado significa que no ocurre absolutamente nada, por muchos ficheros de la aplicación que hayas modificado. De ahí que las herramientas de construcción incrusten un identificador de versión o un resumen criptográfico del contenido en el propio fichero del service worker.
Segundo: la primera carga no está controlada. Cuando un usuario visita tu sitio por primera vez, la página ya se ha descargado antes de que el service worker exista. Ese documento concreto no está controlado por el service worker y sus peticiones no se interceptan. El control empieza en la siguiente navegación. Es la razón por la que muchos desarrolladores concluyen erróneamente que su service worker «no funciona»: han recargado una vez y no han visto ningún efecto. La llamada clients.claim() en el evento de activación permite tomar el control de los clientes existentes de inmediato, pero conviene entender que eso deja a una página que se cargó sin service worker pasando súbitamente a estar controlada por uno, lo que puede producir mezclas de versiones si no lo has pensado.
Tercero, y el más importante: el estado de espera. Un service worker nuevo se queda esperando mientras exista una sola pestaña abierta con la versión anterior. Y recargar la pestaña no la cierra: durante una recarga hay un solapamiento en el que el documento antiguo todavía no se ha descartado cuando el nuevo empieza, así que el control no se cede. El usuario tiene que cerrar todas las pestañas del sitio y volver a entrar. Este comportamiento no es un fallo, es una garantía de seguridad deliberada: evita que una pestaña que lleva media hora abierta con la versión 3 empiece de repente a recibir respuestas generadas por la lógica de la versión 4, con un formato de datos posiblemente incompatible.
skipWaiting() no es el botón mágico que parece
Llamar a self.skipWaiting() en el evento de instalación salta la espera y activa el service worker nuevo de inmediato. Es lo primero que encuentra cualquiera al buscar «mi service worker no se actualiza», y usarlo sin más es un error. Si lo activas mientras hay una pestaña abierta, esa pestaña, que cargó el JavaScript de la versión anterior, empezará a recibir respuestas del service worker de la versión nueva. Si has cambiado el nombre de los ficheros con hash, la carga diferida de un módulo que aún no se había pedido fallará con un error de fragmento no encontrado, y el usuario verá una pantalla en blanco sin ninguna explicación. El patrón correcto es no saltar la espera automáticamente, sino detectar que hay una versión esperando, avisar al usuario con un aviso discreto del tipo «hay una versión nueva disponible» y saltar la espera solo cuando él acepte, recargando a continuación. En la sección 27.11 lo implementamos entero.
27.4.4 Un service worker mínimo, comentado línea a línea
Antes de usar el service worker que genera Angular conviene escribir uno a mano, aunque nunca lo lleves a producción, porque entender lo que la herramienta hace por ti es la diferencia entre configurarla y adivinar.
// 1. Versión del caché. Cambiarla es lo que provoca que en la activación
// se borren las cachés anteriores. Las herramientas de construcción
// la sustituyen automáticamente por un hash del contenido.
const VERSION = 'taskflow-v3';
const CACHE_ARMAZON = `${VERSION}-armazon`;
// 2. El armazón: lo mínimo para pintar algo útil sin red.
// Deliberadamente NO incluye datos, solo la aplicación.
const ARMAZON = [
'/',
'/index.html',
'/offline.html',
'/assets/book.css',
'/assets/logo.svg',
];
// 3. INSTALACIÓN. waitUntil() alarga la vida del evento hasta que la
// promesa se resuelve; sin él, el navegador podría matar el proceso
// a mitad de la descarga y dejar la caché incompleta.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_ARMAZON).then((cache) => cache.addAll(ARMAZON))
// Ojo: addAll es atómico. Si UNO solo de los recursos devuelve un
// estado distinto de 2xx, la promesa entera rechaza y la instalación
// FALLA. Es la causa número uno de «mi service worker no instala».
);
});
// 4. ACTIVACIÓN. Es el único momento seguro para limpiar cachés viejas,
// porque aquí ya sabemos que ningún cliente de la versión anterior
// sigue vivo y necesitando esos ficheros.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((nombres) => Promise.all(
nombres
.filter((n) => !n.startsWith(VERSION))
.map((n) => caches.delete(n)),
))
// Tomamos el control de las pestañas ya abiertas que aún no
// tenían controlador. Sin esto, esperaríamos a la siguiente carga.
.then(() => self.clients.claim()),
);
});
// 5. INTERCEPCIÓN. Se dispara para CADA petición del ámbito.
self.addEventListener('fetch', (event) => {
const req = event.request;
// Regla de oro: no toques lo que no sabes manejar. Si no llamas a
// respondWith(), la petición sigue su curso normal hacia la red,
// que es exactamente lo que quieres para todo lo que no es tu caso.
if (req.method !== 'GET') return;
if (new URL(req.url).origin !== self.location.origin) return;
// Las navegaciones se tratan aparte: si la red falla, en lugar del
// dinosaurio devolvemos nuestra propia página.
if (req.mode === 'navigate') {
event.respondWith(
fetch(req).catch(() => caches.match('/offline.html')),
);
return;
}
// Para el resto: caché primero, y si no está, red.
event.respondWith(
caches.match(req).then((cacheada) => cacheada || fetch(req)),
);
});
Repasemos las decisiones no evidentes de ese fichero, porque cada una responde a un fallo real.
| Línea o decisión | Por qué está ahí | Qué pasa si la quitas |
|---|---|---|
event.waitUntil(...) | Extiende la vida del evento hasta que la promesa se resuelve. | El navegador puede considerar el evento terminado y detener el proceso a mitad de la descarga, dejando una caché parcial que parecerá válida. |
cache.addAll es atómico | O entran todos los recursos o no entra ninguno. | Nada, pero conviene saberlo: una sola URL mal escrita en la lista del armazón impide instalar el service worker entero y el error es poco descriptivo. |
if (req.method !== 'GET') return; | La Cache Storage solo admite peticiones GET. Un POST no se puede cachear. | Intentar cachear un POST lanza una excepción dentro del manejador y la petición falla, rompiendo todas tus escrituras. |
| Filtrar por origen | Las respuestas de otro origen sin CORS son opacas: no puedes leer su estado ni su cuerpo, y ocupan en la cuota mucho más de lo que pesan. | Cachearás respuestas opacas que quizá sean errores 404 y las servirás como si fueran válidas, con un fallo imposible de diagnosticar. |
Tratar mode === 'navigate' aparte | Es la petición del documento HTML. Es la que produce la pantalla del dinosaurio. | Sin esto, el usuario sin conexión ve el error del navegador en lugar de tu página. |
Volver sin llamar a respondWith | Deja que el navegador gestione la petición como siempre. | Si llamas a respondWith para todo y tu lógica tiene un hueco, devolverás undefined y la petición fallará con un error de red aunque la red esté perfecta. |
Un service worker es persistente y con capacidad de responder a todo. Si despliegas uno con un fallo que devuelve respuestas rotas, ese fallo sobrevive a las recargas del usuario, porque el propio service worker roto puede estar sirviendo la versión antigua del fichero del service worker desde su caché. Es la pesadilla clásica y por eso conviene tener preparado desde el primer día un interruptor de emergencia: un service worker mínimo que se limita a desregistrarse y borrar todas las cachés, listo para desplegarlo en la misma URL. Además, no caches nunca el fichero del service worker: sírvelo con Cache-Control: no-cache para que el navegador siempre lo revalide.
// Despliega este fichero en la misma URL que el service worker roto.
// Cada cliente que lo descargue se limpiará solo y volverá a un estado
// sin service worker en la siguiente navegación.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const nombres = await caches.keys();
await Promise.all(nombres.map((n) => caches.delete(n)));
await self.registration.unregister();
const clientes = await self.clients.matchAll({ type: 'window' });
clientes.forEach((c) => c.navigate(c.url));
})());
});
27.5 Las estrategias de caché
Con el proxy programable en la mano, la pregunta pasa a ser de política: ante una petición concreta, ¿qué hacemos? Existen cinco respuestas canónicas. No son un catálogo académico: son las cinco combinaciones sensatas de dos fuentes (caché y red) y dos criterios (rapidez y frescura). Conocerlas por su nombre es útil porque las bibliotecas del sector, y la propia documentación de Angular, las llaman así.
¿Qué priorizas para este recurso?
RAPIDEZ garantizada FRESCURA garantizada
▲ ▲
│ │
┌─────┴──────┐ ┌──────────────┐ ┌────────────┐ ┌─────┴──────┐
│ Cache only │ │ Cache first │ │ Stale- │ │ Network │
│ │ │ (falling │ │ while- │ │ first │
│ Nunca red │ │ back to net) │ │ revalidate │ │ (falling │
│ │ │ │ │ │ │ back cache)│
└────────────┘ └──────────────┘ └────────────┘ └────────────┘
┌────────────┐
│Network only│
│ Nunca │
│ caché │
└────────────┘
| Estrategia | Cómo funciona | Latencia | Frescura | Para qué en TaskFlow |
|---|---|---|---|---|
| Cache first | Busca en caché. Si está, la devuelve sin tocar la red. Si no, va a la red y guarda una copia. | Mínima en el acierto | Puede quedarse indefinidamente obsoleto | Recursos con nombre versionado: main.a1b2c3.js, fuentes, iconos. Como el nombre cambia con el contenido, «obsoleto» no puede ocurrir. |
| Network first | Intenta la red. Si responde, la devuelve y guarda copia. Si falla o expira el tiempo, tira de caché. | La de la red | Máxima cuando hay red | El listado de tareas, el detalle de un proyecto. Datos que deben estar al día pero cuya versión anterior sigue siendo útil sin conexión. |
| Stale-while-revalidate | Devuelve la caché de inmediato y, en paralelo, pide a la red y actualiza la caché para la próxima vez. | Mínima | Siempre un ciclo por detrás | El avatar del usuario, la lista de etiquetas, el catálogo de estados. Datos que cambian poco y donde un ciclo de retraso es inofensivo. |
| Network only | No interviene. Siempre a la red. | La de la red | Total | Autenticación, pagos, cualquier POST, PUT o DELETE, y las peticiones con datos sensibles. |
| Cache only | Solo caché. Si no está, falla. | Mínima | Ninguna | El armazón precacheado en la instalación y la página de respaldo sin conexión. |
27.5.1 Cache first con su matiz importante
async function cacheFirst(req, nombreCache) {
const cache = await caches.open(nombreCache);
const cacheada = await cache.match(req);
if (cacheada) return cacheada;
const respuesta = await fetch(req);
// Solo guardamos respuestas correctas y no opacas.
// Cachear un 404 o un 500 significa servirlo durante días.
if (respuesta.ok && respuesta.type === 'basic') {
// clone() es OBLIGATORIO: el cuerpo de una Response es un flujo
// que se consume una sola vez. Si lo metes en la caché sin clonar,
// el navegador ya no puede entregárselo a la página.
cache.put(req, respuesta.clone());
}
return respuesta;
}
Olvidar el clone() produce el error «Failed to execute 'put' on 'Cache': Response body is already used» o, peor, una página que se queda en blanco sin error visible. El cuerpo de una Response es un ReadableStream y solo se puede leer una vez. Si vas a usar la respuesta dos veces, una para la caché y otra para la página, necesitas dos objetos. Y clona antes de consumir, nunca después.
27.5.2 Network first con tiempo de espera, la estrategia que resuelve el lie-fi
Una estrategia de red primero ingenua no protege contra la conexión lenta que mencionábamos en 27.2: si la red tarda quince segundos, el usuario espera quince segundos aunque hubiera una copia perfectamente utilizable en el disco. La versión útil incorpora un plazo máximo.
async function networkFirst(req, nombre) {
try {
const r = await fetch(req);
const c = await caches.open(nombre);
c.put(req, r.clone());
return r;
} catch {
return caches.match(req);
}
}
// Con la red caída del todo funciona.
// Con una red que tarda 20 s, el usuario
// mira un indicador girando 20 s teniendo
// los datos en el disco. Y si al final falla,
// ha esperado 20 s para nada.
async function networkFirst(req, nombre, msTope = 3000) {
const cache = await caches.open(nombre);
// AbortController corta la petición de verdad,
// no solo deja de escucharla.
const ctl = new AbortController();
const reloj = setTimeout(() => ctl.abort(), msTope);
try {
const r = await fetch(req, { signal: ctl.signal });
clearTimeout(reloj);
if (r.ok) cache.put(req, r.clone());
return r;
} catch {
clearTimeout(reloj);
const cacheada = await cache.match(req);
if (cacheada) {
// Marcamos la respuesta para que la aplicación
// pueda avisar de que el dato no es fresco.
const cab = new Headers(cacheada.headers);
cab.set('X-Desde-Cache', '1');
return new Response(cacheada.body, {
status: cacheada.status, headers: cab,
});
}
return Response.json(
{ error: 'sin_conexion' }, { status: 503 },
);
}
}
Fíjate en la cabecera X-Desde-Cache. Es un detalle pequeño con una consecuencia grande en la interfaz: permite que un interceptor de Angular detecte que los datos que está viendo el usuario proceden del disco y no del servidor, y muestre un distintivo del tipo «mostrando datos guardados hace 12 minutos». Sin ese marcador, el usuario no tiene forma de distinguir entre información actual e información antigua, y eso, en una aplicación de gestión de tareas donde alguien puede reasignar trabajo, no es un detalle estético sino una fuente de errores operativos.
27.5.3 Stale-while-revalidate
async function staleWhileRevalidate(req, nombre) {
const cache = await caches.open(nombre);
const cacheada = await cache.match(req);
// Lanzamos la actualización SIN esperarla.
const enCurso = fetch(req)
.then((r) => { if (r.ok) cache.put(req, r.clone()); return r; })
.catch(() => undefined);
// Si había copia, se devuelve ya. Si no, se espera a la red.
return cacheada ?? (await enCurso) ?? Response.error();
}
// Uso dentro del manejador de fetch. Ojo con el detalle:
// event.waitUntil(enCurso) mantiene vivo el service worker hasta que la
// revalidación termina. Sin eso, el navegador podría detener el proceso
// justo después de responder y la actualización nunca se guardaría.
La expresión stale-while-revalidate es en origen una directiva de la cabecera Cache-Control definida en la RFC 5861, que autoriza a una caché HTTP a servir una respuesta caducada mientras la revalida en segundo plano. El patrón del service worker es la implementación manual de la misma idea. Merece la pena saberlo porque en muchos casos no necesitas un service worker en absoluto: si el recurso es cacheable por HTTP, configurar bien las cabeceras en NestJS o en la CDN te da el mismo efecto sin una línea de JavaScript ni un ciclo de vida que gestionar. Este punto enlaza directamente con el capítulo 25.
27.5.4 Cómo elegir: el mapa de decisión
Llega una petición
│
├── ¿Es un método distinto de GET? ──── sí ──► NETWORK ONLY
│ (y ver 27.9 si
│ debe funcionar
│ sin conexión)
├── ¿Contiene datos sensibles o de
│ autenticación? ───────────────── sí ──► NETWORK ONLY
│
├── ¿La URL lleva hash de contenido?
│ (main.a1b2c3.js, logo.4f5e.svg) ── sí ──► CACHE FIRST
│
├── ¿Es el documento de navegación? ── sí ──► NETWORK FIRST
│ con respaldo al
│ armazón
│
├── ¿El dato cambia a menudo y ver una
│ versión vieja confunde al usuario? sí ──► NETWORK FIRST
│ (tareas, comentarios, estados) con tope de 3 s
│
├── ¿El dato cambia poco y un ciclo de
│ retraso da igual? ──────────────── sí ──► STALE-WHILE-
│ (etiquetas, avatares, catálogos) REVALIDATE
│
└── ¿Es enorme y de terceros?
(vídeos, mapas) ────────────────── sí ──► NO INTERVENIR
27.6 El service worker de Angular
Angular incluye una implementación propia, @angular/service-worker, que no es una biblioteca genérica sino un service worker completo y ya escrito, gobernado por un fichero de configuración declarativo. La diferencia con Workbox, la alternativa más extendida en el ecosistema general, es de filosofía: Workbox te da piezas para construir tu política; el de Angular te da una política implementada y bien probada que tú parametrizas.
# Añade la dependencia, genera ngsw-config.json, crea el manifiesto,
# los iconos de ejemplo y registra el service worker en la aplicación.
ng add @angular/pwa
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideServiceWorker('ngsw-worker.js', {
// Desactivado en desarrollo: un service worker cacheando
// mientras trabajas con recarga en caliente es una tortura.
enabled: !isDevMode(),
// Espera a que la aplicación quede estable antes de registrar.
// Evita competir por el ancho de banda durante el arranque, que
// es justo cuando el usuario está esperando a ver algo.
registrationStrategy: 'registerWhenStable:30000',
}),
],
};
27.6.1 ngsw-config.json explicado campo a campo
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/media/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
],
"dataGroups": [
{
"name": "api-tareas",
"urls": ["/api/tareas", "/api/tareas?*", "/api/proyectos/*/tareas"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 200,
"maxAge": "1h",
"timeout": "3s"
}
},
{
"name": "api-catalogos",
"urls": ["/api/etiquetas", "/api/estados", "/api/usuarios/me"],
"cacheConfig": {
"strategy": "performance",
"maxSize": 50,
"maxAge": "12h"
}
}
],
"navigationUrls": ["/**", "!/**/*.*", "!/**/api/**"]
}
| Campo | Qué significa exactamente | Criterio de uso |
|---|---|---|
assetGroups | Ficheros estáticos que forman parte de la construcción. Angular calcula un hash de cada uno y los versiona en el manifiesto interno. | Todo lo que sale de ng build. |
installMode: "prefetch" | Se descargan todos durante la instalación del service worker. | Para el armazón mínimo. Cuanto más metas aquí, más tarda la instalación y más datos consumes de entrada. |
installMode: "lazy" | Se cachean solo cuando la aplicación los pide por primera vez. | Para imágenes y recursos pesados que no todo usuario necesita. |
updateMode: "prefetch" | Al llegar una versión nueva, los recursos que ya estaban en caché se actualizan de golpe. | Combinado con lazy es lo habitual: no descargo lo que no has usado, pero mantengo al día lo que sí. |
dataGroups | Peticiones a API. No se versionan con la construcción, se gobiernan por edad y tamaño. | Todo lo que sea /api/.... |
strategy: "freshness" | Es network first. Con timeout recurre a la caché si la red tarda demasiado. | Datos que cambian. |
strategy: "performance" | Es cache first con caducidad por maxAge. | Catálogos y datos casi estáticos. |
maxSize | Número máximo de respuestas guardadas en ese grupo, no bytes. Se descarta la menos usada recientemente. | Dimensiónalo pensando en cuántas URL distintas genera tu paginación y tus filtros. |
navigationUrls | Qué URL se consideran navegaciones y por tanto se responden con index.html, que es lo que necesita un enrutador del lado del cliente. | Los patrones con ! son exclusiones. Es imprescindible excluir /api/**. |
navigationUrls
Si no excluyes las rutas de la API, una petición a /api/tareas que devuelva 404 será tratada como una navegación y el service worker responderá con el contenido de index.html. Tu código de Angular recibirá entonces un estado 200 con un cuerpo que empieza por <!DOCTYPE html> y morirá al intentar interpretarlo como JSON, con un mensaje del tipo «Unexpected token < in JSON at position 0». Es un error desconcertante porque el síntoma no apunta en absoluto a la causa, y solo se manifiesta en producción, que es donde el service worker está activo. La exclusión "!/**/api/**" del ejemplo anterior es lo que lo evita.
Es importante conocer los límites antes de prometer nada. No cachea peticiones que no sean GET: para las escrituras sin conexión tendrás que construir tu propia cola, que es lo que hacemos en 27.9. No permite lógica personalizada por petición: si necesitas una política que no sea freshness ni performance, no cabe en la configuración. Y no gestiona la sincronización en segundo plano. Cuando esos límites te aprietan, la salida es Workbox o un service worker propio, con el coste de mantenimiento que eso implica.
27.6.2 Cómo se depura
Angular expone un punto de diagnóstico que devuelve el estado interno del service worker en texto plano, y es la primera parada cuando algo no cuadra.
# Abre esta URL en la aplicación desplegada:
https://taskflow.example.com/ngsw/state
# Devuelve algo como:
# NGSW Debug Info:
# Driver state: NORMAL ((nominal))
# Latest manifest hash: 4f2a91c...
# Last update check: 3m14s ago
# === Version 4f2a91c ===
# Clients: 8b1c-...
# === Idle Task Queue ===
# Last update tick: 2s ago
| Estado del controlador | Qué significa | Qué hacer |
|---|---|---|
NORMAL | Todo correcto. | Nada. |
EXISTING_CLIENTS_ONLY | Hay un error en la versión más reciente. Los clientes ya existentes siguen atendidos con lo que tienen, pero los nuevos van directos a la red. | Suele indicar que un fichero del manifiesto no se pudo descargar o que su hash no coincide. Revisa que el despliegue subió todos los ficheros y que la CDN no está sirviendo una mezcla de dos versiones. |
SAFE_MODE | Fallo grave. El service worker se aparta y deja pasar todo a la red. | Es el modo de rendición. Revisa la consola del service worker en las herramientas de desarrollo. |
Angular guarda en ngsw.json el hash de cada fichero. Si el service worker descarga main.a1b2c3.js y el contenido no produce el hash esperado, considera la versión corrupta y pasa a EXISTING_CLIENTS_ONLY. ¿Cuándo ocurre esto en la vida real? Cuando el despliegue no es atómico. Si tienes dos servidores detrás de un balanceador y actualizas uno primero, un cliente puede pedir ngsw.json al servidor nuevo y main.js al viejo. La solución pasa por desplegar en un almacenamiento de objetos con conmutación atómica, o por mantener las versiones antiguas de los ficheros accesibles durante un tiempo prudencial tras el despliegue, que además resuelve el problema del usuario con una pestaña abierta desde ayer.
27.7 El manifiesto y la instalación
{
"name": "TaskFlow · Gestión de tareas de equipo",
"short_name": "TaskFlow",
"description": "Organiza proyectos, tareas y etiquetas con tu equipo.",
"start_url": "/?origen=pwa",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0b1020",
"theme_color": "#5b8cff",
"lang": "es-ES",
"dir": "ltr",
"icons": [
{ "src": "/assets/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/assets/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{
"src": "/assets/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Nueva tarea",
"url": "/tareas/nueva",
"icons": [{ "src": "/assets/icons/nueva-96.png", "sizes": "96x96" }]
},
{ "name": "Mis tareas de hoy", "url": "/tareas?filtro=hoy" }
]
}
| Campo | Detalle que casi nadie tiene en cuenta |
|---|---|
short_name | Es el que aparece bajo el icono en la pantalla de inicio, donde caben muy pocos caracteres. Si es largo, el sistema lo trunca con puntos suspensivos. Doce caracteres es un límite prudente. |
start_url | El parámetro ?origen=pwa no es decorativo: permite distinguir en analítica las sesiones lanzadas desde el icono instalado de las que llegan por el navegador. Es la única forma sencilla de medir si la instalación aporta algo. |
display | standalone quita la barra de direcciones y es lo habitual. fullscreen ocupa toda la pantalla y solo tiene sentido en juegos o quioscos. minimal-ui conserva controles mínimos de navegación. browser renuncia a instalar. |
background_color | Es el color de la pantalla de arranque que el sistema muestra mientras la aplicación carga. Si no coincide con el fondo real de tu aplicación, el usuario ve un destello de color al abrir. Debe ser el mismo color que el fondo del cuerpo. |
purpose: "maskable" | Android recorta los iconos con la forma que tenga configurada el lanzador: círculo, cuadrado redondeado, gota. Un icono normal se recorta y pierde los bordes. Un icono maskable se diseña con una zona de seguridad central, dejando margen sacrificable alrededor. Sin él, tu logo aparecerá cortado en muchos dispositivos. |
shortcuts | Accesos directos al mantener pulsado el icono. Cuestan cinco líneas y mejoran mucho la sensación de aplicación de verdad. |
27.7.1 Controlar el momento de la invitación a instalar
Los navegadores basados en Chromium disparan el evento beforeinstallprompt cuando la aplicación cumple los criterios. Si lo cancelas, puedes guardarlo y disparar la invitación cuando tú decidas. Aquí la parte importante no es la técnica sino el criterio de producto: pedir la instalación en el primer segundo de la primera visita es la forma más rápida de que el usuario diga que no, y esa negativa el navegador la recuerda durante un tiempo. Pídelo cuando el usuario haya demostrado interés.
import { Injectable, signal, inject, DOCUMENT } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class InstalacionService {
private readonly doc = inject(DOCUMENT);
private evento: any = null;
/** La interfaz se suscribe a esta señal para mostrar el botón. */
readonly sePuedeInstalar = signal(false);
readonly yaInstalada = signal(false);
constructor() {
const win = this.doc.defaultView!;
win.addEventListener('beforeinstallprompt', (e: Event) => {
// Sin preventDefault, el navegador muestra su propio aviso
// cuando quiere y perdemos el control del momento.
e.preventDefault();
this.evento = e;
this.sePuedeInstalar.set(true);
});
win.addEventListener('appinstalled', () => {
this.evento = null;
this.sePuedeInstalar.set(false);
this.yaInstalada.set(true);
});
// Detecta si ya se está ejecutando instalada.
this.yaInstalada.set(
win.matchMedia('(display-mode: standalone)').matches,
);
}
async invitar(): Promise<'aceptada' | 'rechazada' | 'no_disponible'> {
if (!this.evento) return 'no_disponible';
// prompt() debe llamarse dentro de un gesto del usuario.
// Si lo llamas desde un temporizador, el navegador lo bloquea.
await this.evento.prompt();
const { outcome } = await this.evento.userChoice;
// El evento es de un solo uso. Hay que descartarlo.
this.evento = null;
this.sePuedeInstalar.set(false);
return outcome === 'accepted' ? 'aceptada' : 'rechazada';
}
}
Safari no implementa beforeinstallprompt. En iOS la instalación se hace manualmente desde el menú de compartir, con la opción «Añadir a pantalla de inicio». La práctica habitual es detectar que estamos en Safari en iOS y que la aplicación no se está ejecutando ya en modo instalado, y mostrar una vez, de forma discreta y descartable, una indicación visual de dónde está esa opción. No conviene insistir: mostrar ese aviso en cada visita es una de las cosas que más molestan a los usuarios de iOS.
27.8 Almacenamiento en el cliente
Cachear respuestas HTTP resuelve la lectura de lo que el usuario ya vio, pero no basta para trabajar sin conexión de verdad. Para eso hace falta guardar datos estructurados, consultarlos y modificarlos. El navegador ofrece cinco mecanismos con propósitos muy distintos y elegir mal aquí tiene consecuencias que aparecen tarde.
| Mecanismo | Capacidad típica | API | Datos | Accesible desde el SW | Cuándo usarlo |
|---|---|---|---|---|---|
localStorage | 5-10 MB | Síncrona | Solo cadenas | No | Preferencias diminutas: tema elegido, idioma, si ya vio un aviso. |
sessionStorage | 5-10 MB | Síncrona | Solo cadenas | No | Estado de una pestaña concreta que debe morir al cerrarla. |
IndexedDB | Cientos de MB o más, según cuota | Asíncrona, basada en eventos | Objetos estructurados, ficheros, blobs | Sí | Datos de la aplicación, colas de sincronización, cualquier cosa seria. |
Cache Storage | Comparte cuota con IndexedDB | Asíncrona, con promesas | Pares petición/respuesta HTTP | Sí | Recursos y respuestas HTTP. No es para datos de dominio. |
| Cookies | ~4 KB por cookie | Síncrona | Cadenas | Se envían solas | Identificador de sesión, y poco más. Viajan en cada petición, así que todo lo que metas ahí encarece toda tu red. |
localStorage es una mala elección casi siempre
Hay tres razones y las tres son serias. La primera es que su API es síncrona: cada lectura y cada escritura bloquea el hilo principal, el mismo que pinta la interfaz. Guardar un objeto de 200 KB serializado provoca un tirón perceptible en un móvil modesto. La segunda es que solo almacena cadenas, lo que obliga a serializar y deserializar constantemente, con el coste de rendimiento y la pérdida de tipos que eso conlleva: un Date entra como objeto y sale como cadena, y ese detalle produce errores muy difíciles de rastrear. La tercera, y la más grave: es accesible desde cualquier JavaScript de la página, así que guardar ahí un token de acceso significa que cualquier vulnerabilidad de scripting entre sitios, incluida una que llegue a través de una dependencia comprometida, se lleva la sesión del usuario. Sobre esto último, vuelve al capítulo 12.
27.8.1 IndexedDB sin dolor
IndexedDB es una base de datos transaccional, orientada a objetos y con índices, empotrada en el navegador. Es potentísima y su API nativa es genuinamente desagradable: está basada en eventos, es anterior a las promesas y una operación sencilla se convierte en una escalera de callbacks. La recomendación profesional es no usarla directamente sino a través de una envoltura fina. La biblioteca idb, mantenida por Jake Archibald, es la opción estándar del sector y pesa unos dos kilobytes.
const req = indexedDB.open('taskflow', 1);
req.onupgradeneeded = () => {
req.result.createObjectStore('tareas', { keyPath: 'id' });
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction('tareas', 'readonly');
const store = tx.objectStore('tareas');
const get = store.get(42);
get.onsuccess = () => console.log(get.result);
get.onerror = () => console.error(get.error);
};
req.onerror = () => console.error(req.error);
import { openDB } from 'idb';
const db = await openDB('taskflow', 1, {
upgrade(db) {
db.createObjectStore('tareas', { keyPath: 'id' });
},
});
const tarea = await db.get('tareas', 42);
console.log(tarea);
import { openDB, DBSchema, IDBPDatabase } from 'idb';
/** Copia local de una tarea, con metadatos de sincronización. */
export interface TareaLocal {
id: string; // uuid; provisional si se creó sin conexión
titulo: string;
descripcion: string | null;
estado: 'pendiente' | 'en_curso' | 'hecha';
proyectoId: string;
version: number; // versión del servidor, para el bloqueo optimista
actualizadaEn: string; // ISO 8601
pendienteDeSubir: boolean; // hay cambios locales sin confirmar
}
/** Una operación en espera de ser enviada al servidor. */
export interface OperacionPendiente {
id?: number; // autoincremental
claveIdempotencia: string; // uuid, viaja en la cabecera
metodo: 'POST' | 'PATCH' | 'DELETE';
url: string;
cuerpo: unknown;
entidadId: string;
creadaEn: number;
intentos: number;
ultimoError?: string;
}
interface EsquemaTaskFlow extends DBSchema {
tareas: {
key: string;
value: TareaLocal;
// Los índices permiten consultar sin recorrer todo el almacén.
indexes: { 'por-proyecto': string; 'por-estado': string };
};
cola: {
key: number;
value: OperacionPendiente;
indexes: { 'por-entidad': string };
};
meta: { key: string; value: unknown };
}
let promesaDb: Promise<IDBPDatabase<EsquemaTaskFlow>> | null = null;
export function abrirDb() {
// Memorizamos la promesa, no la base de datos: si dos partes del
// código llaman a la vez, ambas esperan la MISMA apertura en lugar
// de abrir dos conexiones que competirían entre sí.
promesaDb ??= openDB<EsquemaTaskFlow>('taskflow', 1, {
upgrade(db, versionAnterior) {
// Las migraciones son incrementales y sin 'break': la caída de
// un caso al siguiente aplica todos los pasos que faltan a un
// usuario que llevaba meses sin abrir la aplicación.
switch (versionAnterior) {
case 0: {
const tareas = db.createObjectStore('tareas', { keyPath: 'id' });
tareas.createIndex('por-proyecto', 'proyectoId');
tareas.createIndex('por-estado', 'estado');
const cola = db.createObjectStore('cola', {
keyPath: 'id', autoIncrement: true,
});
cola.createIndex('por-entidad', 'entidadId');
db.createObjectStore('meta');
}
// case 1: aquí irían los cambios de la versión 2, y así
// sucesivamente. Nunca se borra un caso anterior.
}
},
blocked() {
// Otra pestaña tiene abierta una versión antigua y bloquea la
// actualización del esquema. Hay que avisar al usuario.
console.warn('Cierra las demás pestañas para actualizar TaskFlow.');
},
blocking() {
// Somos NOSOTROS los que bloqueamos a otra pestaña que quiere
// actualizar. Cerramos para dejarla pasar.
promesaDb?.then((db) => db.close());
promesaDb = null;
},
});
return promesaDb;
}
Los manejadores blocked y blocking parecen accesorios y no lo son. IndexedDB no puede cambiar de esquema mientras otra conexión tenga abierta la versión anterior, así que un usuario con tres pestañas de TaskFlow abiertas se queda atascado: la pestaña nueva espera indefinidamente a que las viejas cierren. Sin esos manejadores, el síntoma es que la aplicación se queda cargando para siempre en un caso que nunca reproduces en desarrollo. Y una advertencia de diseño: las migraciones de IndexedDB solo van hacia adelante. Un usuario que ya está en la versión 3 no puede volver a la 2, así que si haces una reversión del despliegue tendrás clientes con un esquema más nuevo que el código. La forma de sobrevivir a eso es que el código sea tolerante con campos que no conoce y que nunca dependa de que un campo nuevo exista.
27.8.2 Cuotas, persistencia y expulsión
El navegador no te da almacenamiento ilimitado ni te garantiza conservar lo que guardas. En condiciones de poco espacio en disco, puede expulsar todos los datos de un origen sin previo aviso. La API de almacenamiento permite consultar la cuota y solicitar que el origen se marque como persistente, lo que hace que el navegador solo borre los datos si el usuario lo pide explícitamente.
@Injectable({ providedIn: 'root' })
export class CuotaService {
async estado() {
if (!navigator.storage?.estimate) return null;
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
return {
usadoMb: +(usage / 1024 / 1024).toFixed(1),
totalMb: +(quota / 1024 / 1024).toFixed(1),
porcentaje: quota ? Math.round((usage / quota) * 100) : 0,
};
}
/**
* Pide que el navegador NO expulse nuestros datos automáticamente.
* El navegador decide según su propia heurística: si la aplicación
* está instalada, si el usuario la visita a menudo, si le ha dado
* permiso de notificaciones... No se puede forzar.
*/
async pedirPersistencia(): Promise<boolean> {
if (!navigator.storage?.persist) return false;
if (await navigator.storage.persisted()) return true;
return navigator.storage.persist();
}
}
El almacenamiento del cliente puede desaparecer en cualquier momento: por presión de disco, por una limpieza del usuario, por la política de expulsión de datos de sitios poco visitados que aplican algunos navegadores, o porque el usuario está en modo privado. La conclusión de diseño es inflexible: el servidor es la única fuente de verdad y el cliente es una caché. Si el usuario puede perder trabajo porque el navegador borró IndexedDB, tu diseño tiene un fallo, no el navegador. En la práctica esto significa dos cosas: sincronizar la cola de escrituras cuanto antes en lugar de dejarla acumularse, y avisar visiblemente cuando hay cambios sin subir.
27.9 Escritura sin conexión: la cola de sincronización
Llegamos a la parte difícil. Que el usuario pueda leer sin conexión es cuestión de cachear. Que pueda escribir sin conexión obliga a resolver cuatro problemas que no existen cuando hay red: dónde se guarda lo que aún no se ha enviado, cómo se identifica una entidad que el servidor todavía no conoce, qué ocurre si la misma petición se envía dos veces, y qué se hace cuando el servidor tiene una versión distinta de lo que el usuario editó.
El usuario crea una tarea sin conexión
│
▼
┌───────────────────────────┐
│ 1. Escritura optimista │ Se genera un uuid en el cliente y la
│ en IndexedDB │ tarea aparece en pantalla al instante,
│ (tarea + operación) │ marcada como «pendiente de subir».
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ 2. La operación entra en │ { claveIdempotencia, método, url,
│ la cola de IndexedDB │ cuerpo, intentos: 0 }
└───────────┬───────────────┘
│
│ ... el usuario sigue trabajando ...
│
▼ vuelve la conexión (evento 'online' o comprobación)
┌───────────────────────────┐
│ 3. Se vacía la cola en │ Estrictamente en orden: la tarea debe
│ ORDEN de llegada │ crearse antes de que se edite.
└───────────┬───────────────┘
│
┌───────┴────────┬──────────────┬─────────────────┐
▼ ▼ ▼ ▼
2xx éxito 409 conflicto 4xx permanente error de red
│ │ │ │
▼ ▼ ▼ ▼
se sustituye se resuelve se descarta y se reintenta con
el uuid local (ver 27.9.4) se avisa al retroceso
por el del usuario exponencial
servidor
27.9.1 Identificadores provisionales
Si el servidor asigna el identificador, una tarea creada sin conexión no tiene ninguno hasta que se sincroniza. Y sin identificador no puedes referirte a ella, ni editarla, ni asociarle una etiqueta. La solución habitual es generar en el cliente un UUID de versión 4 y usar ese mismo valor como clave primaria también en el servidor.
| Enfoque | Cómo funciona | Valoración |
|---|---|---|
| UUID generado en el cliente y aceptado por el servidor | El cliente llama a crypto.randomUUID() y el servidor usa ese valor como clave primaria. | El mejor. No hay reconciliación posterior porque el identificador nunca cambia. Requiere que la clave primaria sea un UUID, decisión que se toma al diseñar la base de datos. Como efecto secundario, da idempotencia casi gratis: reenviar la creación choca con la clave primaria. |
| Identificador temporal y sustitución posterior | El cliente usa tmp_xxx y al sincronizar reemplaza todas las referencias por el identificador real. | Funciona, pero tienes que perseguir todas las referencias en toda la aplicación. Cada referencia olvidada es un error silencioso. |
| Prohibir crear sin conexión | Solo se permite editar lo que ya existe. | Perfectamente legítimo. Si el negocio no necesita crear sin conexión, esta decisión elimina la mitad de la complejidad del capítulo. Considérala en serio antes de descartarla. |
Hay un matiz de rendimiento que conecta con el capítulo 19. Un UUID v4 es aleatorio, así que las inserciones caen en posiciones dispersas del índice de clave primaria y provocan fragmentación en un índice agrupado. En PostgreSQL, con su almacenamiento en montón, el impacto es mucho menor que en MySQL con InnoDB, donde la clave primaria sí es agrupada. Si el volumen te preocupa, existen los identificadores UUID v7, que incorporan una marca temporal en los bits más significativos y por tanto son crecientes en el tiempo, conservando la unicidad global. Comprueba el soporte en tu versión de PostgreSQL y en la biblioteca que uses antes de adoptarlo.
27.9.2 La cola de operaciones
import { Injectable, signal, inject } from '@angular/core';
import { abrirDb, OperacionPendiente } from './db';
@Injectable({ providedIn: 'root' })
export class ColaService {
/** Cantidad de operaciones sin subir; la interfaz la muestra. */
readonly pendientes = signal(0);
readonly sincronizando = signal(false);
private vaciadoEnCurso: Promise<void> | null = null;
async encolar(op: Omit<OperacionPendiente, 'id' | 'creadaEn' | 'intentos'>) {
const db = await abrirDb();
await db.add('cola', { ...op, creadaEn: Date.now(), intentos: 0 });
await this.refrescarContador();
// Intento inmediato: si hay red, el usuario ni se entera de que
// ha pasado por una cola.
if (navigator.onLine) void this.vaciar();
}
/**
* Vacía la cola en orden estricto de llegada.
*
* El orden importa: si el usuario creó la tarea A y luego la editó,
* enviar la edición antes que la creación produce un 404. Por eso
* NO se paralelizan las operaciones, aunque sea más lento.
*/
async vaciar(): Promise<void> {
// Reutilizamos la promesa en curso para que dos disparos
// simultáneos (el evento 'online' y un temporizador, por ejemplo)
// no vacíen la cola dos veces a la vez.
this.vaciadoEnCurso ??= this.vaciarInterno()
.finally(() => { this.vaciadoEnCurso = null; });
return this.vaciadoEnCurso;
}
private async vaciarInterno(): Promise<void> {
const db = await abrirDb();
this.sincronizando.set(true);
try {
// Leemos las claves ordenadas: el autoincremental garantiza
// el orden cronológico de inserción.
let claves = await db.getAllKeys('cola');
for (const clave of claves) {
const op = await db.get('cola', clave);
if (!op) continue;
const resultado = await this.enviar(op);
if (resultado === 'ok' || resultado === 'descartar') {
await db.delete('cola', clave);
} else if (resultado === 'reintentar') {
// Guardamos el intento fallido y PARAMOS. Seguir con las
// siguientes rompería el orden causal.
await db.put('cola', { ...op, intentos: op.intentos + 1 });
break;
}
}
} finally {
this.sincronizando.set(false);
await this.refrescarContador();
}
}
private async enviar(op: OperacionPendiente) {
try {
const res = await fetch(op.url, {
method: op.metodo,
headers: {
'Content-Type': 'application/json',
// La clave viaja en la cabecera: el servidor la usa para
// detectar reenvíos. Ver capítulo 25, sección de idempotencia.
'Idempotency-Key': op.claveIdempotencia,
},
body: op.metodo === 'DELETE' ? undefined : JSON.stringify(op.cuerpo),
});
if (res.ok) return 'ok' as const;
// 409: el servidor tiene una versión más nueva.
if (res.status === 409) {
await this.registrarConflicto(op, await res.json());
return 'descartar' as const;
}
// 4xx que no es 408 ni 429: la petición está mal y reintentarla
// fallará siempre. Descartar y avisar es lo correcto; dejarla en
// la cola bloquearía todas las operaciones posteriores para
// siempre.
if (res.status >= 400 && res.status < 500
&& res.status !== 408 && res.status !== 429) {
await this.registrarFalloPermanente(op, res.status);
return 'descartar' as const;
}
return 'reintentar' as const;
} catch {
// Fallo de red: sigue sin haber conexión.
return 'reintentar' as const;
}
}
private async refrescarContador() {
const db = await abrirDb();
this.pendientes.set(await db.count('cola'));
}
private async registrarConflicto(op: OperacionPendiente, cuerpo: unknown) { /* ... */ }
private async registrarFalloPermanente(op: OperacionPendiente, estado: number) { /* ... */ }
}
Presta atención al tratamiento de los errores 4xx permanentes. Es el punto que más veces se implementa mal. Si una operación falla con un 422 porque su cuerpo es inválido, reintentarla eternamente no la va a arreglar, y como la cola se procesa en orden, esa operación bloquea todas las que vienen detrás. El usuario ve un contador de pendientes que nunca baja y pierde todo lo que hizo después. En la jerga de las colas de mensajes esto se llama poison message, y la solución es la misma que allí: apartar la operación a una zona de mensajes fallidos, avisar al usuario de que ese cambio concreto no se pudo guardar, y dejar que el resto siga su curso. Es exactamente el patrón de cola de mensajes fallidos que veías en el capítulo 11 con BullMQ, aplicado en el cliente.
27.9.3 Detectar la conexión de verdad
hayRed = signal(navigator.onLine);
window.addEventListener('online',
() => this.hayRed.set(true));
window.addEventListener('offline',
() => this.hayRed.set(false));
// navigator.onLine solo dice si hay una
// interfaz de red activa. Devuelve true
// conectado a un wifi de hotel sin
// autenticar, con el cable puesto y el
// router caído, o con el móvil en una
// zona con una raya de cobertura.
// Es fiable para el NO y no lo es
// para el SÍ.
@Injectable({ providedIn: 'root' })
export class ConexionService {
readonly enLinea = signal(navigator.onLine);
private ultimaComprobacion = 0;
constructor() {
addEventListener('offline', () => this.enLinea.set(false));
// El evento 'online' solo dispara una VERIFICACIÓN,
// no se cree a pies juntillas.
addEventListener('online', () => void this.verificar());
setInterval(() => void this.verificar(), 30_000);
}
/** Golpea un punto real y barato del servidor. */
async verificar(): Promise<boolean> {
if (!navigator.onLine) {
this.enLinea.set(false);
return false;
}
if (Date.now() - this.ultimaComprobacion < 5000) {
return this.enLinea();
}
this.ultimaComprobacion = Date.now();
try {
const res = await fetch('/api/salud', {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(4000),
});
this.enLinea.set(res.ok);
return res.ok;
} catch {
this.enLinea.set(false);
return false;
}
}
}
27.9.4 Conflictos: qué hacer cuando los dos han cambiado lo mismo
El usuario A edita el título de una tarea en el metro. Mientras tanto, el usuario B edita la misma tarea desde la oficina. Cuando A recupera la conexión, su cambio llega tarde. ¿Qué debe ocurrir?
| Política | Cómo se implementa | Cuándo es aceptable | Riesgo |
|---|---|---|---|
| Gana el último que escribe | No se hace nada especial: el PATCH sobrescribe. | Datos personales que solo edita su dueño, o campos donde la última voluntad es la correcta. | Pérdida silenciosa de trabajo ajeno. El usuario B nunca sabe que su cambio desapareció. |
| Gana el primero (bloqueo optimista) | El cliente envía la version que tenía; el servidor responde 409 si ya no coincide. | El caso general. Es lo que hace MikroORM con @Property({ version: true }), capítulo 17. | Requiere una interfaz que explique el conflicto sin frustrar al usuario. |
| Fusión por campos | Se envía solo lo que cambió y el servidor fusiona campo a campo. Solo hay conflicto si ambos tocaron el mismo campo. | Entidades con muchos campos independientes. Encaja bien con un PATCH de fusión. | Puede producir estados incoherentes: A cambió el estado a «hecha» y B la reasignó a otra persona; el resultado fusionado quizá no tenga sentido de negocio. |
| Resolución manual | Se guardan ambas versiones y se pregunta al usuario cuál quiere. | Documentos y textos largos, donde perder el trabajo es caro. | Coste de interfaz alto. Solo para lo que de verdad lo merece. |
| Tipos de datos replicados sin conflicto | Estructuras matemáticas que convergen sin coordinación (Yjs, Automerge). | Edición colaborativa en tiempo real, tipo documento compartido. | Complejidad y peso muy altos. Enorme exageración para una lista de tareas. |
@Patch(':id')
async actualizar(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ActualizarTareaDto,
@Headers('if-match') etag?: string,
) {
const tarea = await this.em.findOneOrFail(Tarea, { id });
// La versión que el cliente creía tener, tomada de la cabecera
// If-Match según el capítulo 25, o del cuerpo si prefieres.
const versionCliente = Number(etag?.replace(/"/g, '') ?? dto.version);
if (versionCliente !== tarea.version) {
// 409 con el estado ACTUAL del servidor: el cliente necesita
// saber contra qué está compitiendo para poder mostrar la
// comparación al usuario. Devolver solo «conflicto» sin datos
// deja al cliente sin nada que hacer salvo descartar el cambio.
throw new ConflictException({
type: 'https://taskflow.example.com/errores/conflicto-version',
title: 'La tarea fue modificada por otra persona',
status: 409,
versionEsperada: versionCliente,
versionActual: tarea.version,
estadoActual: this.mapper.aDto(tarea),
cambiosRechazados: dto,
});
}
this.em.assign(tarea, dto);
await this.em.flush(); // MikroORM incrementa version solo
return this.mapper.aDto(tarea);
}
La interfaz importa tanto como el mecanismo. Un mensaje de «error 409» es inaceptable. Lo que funciona es un aviso que diga con claridad qué ha pasado y ofrezca tres salidas concretas: conservar la versión del servidor y descartar mi cambio, imponer mi versión sobrescribiendo la del servidor, o abrir una comparación campo a campo para elegir. Y algo que se olvida siempre: no descartes nunca el texto que el usuario escribió antes de que él decida. Aunque el conflicto se resuelva a favor del servidor, ese texto debe seguir estando en algún sitio del que se pueda copiar. Perder diez minutos de escritura por un aviso mal diseñado destruye la confianza en la aplicación de forma duradera.
27.9.5 Sincronización en segundo plano
La cola que hemos construido se vacía cuando la aplicación está abierta. La API de Background Sync va un paso más allá: permite pedir al navegador que despierte al service worker y ejecute la sincronización cuando recupere la conexión, aunque el usuario haya cerrado la pestaña.
self.addEventListener('sync', (event) => {
if (event.tag === 'taskflow-cola') {
// Si la promesa rechaza, el navegador reintentará más tarde
// con su propia política de retroceso. No la implementes tú.
event.waitUntil(vaciarColaDesdeServiceWorker());
}
});
async pedirSincronizacionEnSegundoPlano() {
const reg = await navigator.serviceWorker?.ready;
// La API no existe en todos los navegadores. La degradación es
// sencilla: si no está, nos conformamos con sincronizar cuando la
// aplicación esté abierta, que cubre la mayoría de los casos.
if (!reg || !('sync' in reg)) return false;
try {
await (reg as any).sync.register('taskflow-cola');
return true;
} catch {
return false;
}
}
Background Sync está implementado en los navegadores basados en Chromium y no lo está en Firefox ni en Safari. La consecuencia práctica es que no puedes construir tu diseño sobre esta API: tiene que ser una mejora opcional sobre una cola que ya funciona con la aplicación abierta. Comprueba el estado actual antes de decidir, porque estas cosas cambian. Y aunque esté disponible, el navegador decide cuándo ejecutar la sincronización según su propia heurística de batería y de red; no hay garantía de inmediatez.
27.10 Notificaciones push de extremo a extremo
Las notificaciones push son la única forma que tiene una aplicación web de dirigirse al usuario cuando no la está usando. Conviene entender bien la arquitectura antes de escribir código, porque intervienen tres partes y una de ellas no es tuya.
┌──────────────┐ ┌───────────────────────┐
│ TaskFlow │ 1. subscribe() │ Servicio push del │
│ (Angular) │ ─────────────────► │ navegador │
│ │ con la clave │ (FCM en Chrome, │
│ │ pública VAPID │ Mozilla, Apple...) │
│ │ ◄───────────────── │ │
└──────┬───────┘ 2. PushSubscription└──────────┬───────────┘
│ { endpoint, keys } │
│ │
│ 3. POST /api/push/suscripciones │ 5. entrega el
▼ │ mensaje
┌──────────────┐ │
│ NestJS │ 4. webpush.sendNotification │
│ + MikroORM │ ───────────────────────────────┘
│ │ firmado con la clave
└──────────────┘ PRIVADA VAPID
│
▼
┌──────────────────────┐
│ Service worker │
│ evento 'push' │ 6. showNotification()
│ (app cerrada) │
└──────────────────────┘
El punto que sorprende a quien viene de aplicaciones nativas es que tú no entregas la notificación. Tu servidor se la entrega al servicio push del fabricante del navegador, y es ese servicio el que la hace llegar al dispositivo. Las claves VAPID sirven para que ese intermediario sepa quién eres: firmas la petición con tu clave privada y el servicio verifica la firma con la pública, que le llegó en el momento de la suscripción. Esto impide que un tercero que obtenga el punto de entrega de un usuario pueda enviarle notificaciones en tu nombre.
npx web-push generate-vapid-keys
# Public Key: BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U
# Private Key: UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls
# La pública va en el código de Angular (es pública, no pasa nada).
# La privada va en el gestor de secretos del servidor. NUNCA en git.
27.10.1 Persistir la suscripción con MikroORM
import { Entity, PrimaryKey, Property, ManyToOne, Index, Unique } from '@mikro-orm/core';
import { v4 } from 'uuid';
import { Usuario } from '../usuarios/usuario.entity';
@Entity({ tableName: 'suscripciones_push' })
export class SuscripcionPush {
@PrimaryKey({ type: 'uuid' })
id: string = v4();
@ManyToOne(() => Usuario, { deleteRule: 'cascade' })
@Index()
usuario!: Usuario;
/**
* URL única que identifica el canal de este navegador concreto.
* Es larga (puede pasar de 500 caracteres), así que 'text'.
* Única: si el mismo navegador se resuscribe, actualizamos en
* lugar de duplicar y enviar la notificación dos veces.
*/
@Property({ type: 'text' })
@Unique()
endpoint!: string;
@Property({ type: 'text' })
p256dh!: string; // clave pública del cliente, para cifrar la carga
@Property({ type: 'text' })
auth!: string; // secreto de autenticación
@Property({ nullable: true })
agenteUsuario?: string;
@Property()
creadaEn: Date = new Date();
/** Para poder limpiar suscripciones que ya no se usan. */
@Property({ nullable: true })
ultimoEnvioCorrecto?: Date;
}
27.10.2 El servicio de envío en NestJS
import { Injectable, Logger } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { ConfigService } from '@nestjs/config';
import * as webpush from 'web-push';
import { SuscripcionPush } from './suscripcion-push.entity';
interface CargaPush {
titulo: string;
cuerpo: string;
url: string;
etiqueta?: string;
}
@Injectable()
export class PushService {
private readonly log = new Logger(PushService.name);
constructor(
private readonly em: EntityManager,
config: ConfigService,
) {
webpush.setVapidDetails(
// Un contacto real: si tu servidor genera problemas, el
// operador del servicio push necesita a quién avisar.
'mailto:soporte@taskflow.example.com',
config.getOrThrow('VAPID_PUBLIC_KEY'),
config.getOrThrow('VAPID_PRIVATE_KEY'),
);
}
async enviarAUsuario(usuarioId: string, carga: CargaPush): Promise<void> {
const suscripciones = await this.em.find(
SuscripcionPush, { usuario: usuarioId },
);
// Un usuario puede tener varios dispositivos. Se envía a todos
// en paralelo y se recogen los resultados sin que uno tumbe
// a los demás: allSettled, no all.
const resultados = await Promise.allSettled(
suscripciones.map((s) => this.enviarA(s, carga)),
);
const fallos = resultados.filter((r) => r.status === 'rejected').length;
if (fallos) {
this.log.warn(`Push a ${usuarioId}: ${fallos}/${suscripciones.length} fallaron`);
}
await this.em.flush();
}
private async enviarA(s: SuscripcionPush, carga: CargaPush) {
try {
await webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
JSON.stringify(carga),
{
TTL: 60 * 60 * 24, // si no se entrega en 24 h, se descarta
urgency: 'normal',
},
);
s.ultimoEnvioCorrecto = new Date();
} catch (e: any) {
// 404 y 410 significan que la suscripción ya no existe:
// el usuario desinstaló, revocó el permiso o limpió los datos.
// Hay que BORRARLA. Si no, la tabla crece indefinidamente con
// canales muertos y cada envío desperdicia una petición.
if (e.statusCode === 404 || e.statusCode === 410) {
this.em.remove(s);
return;
}
// 413: la carga es demasiado grande. Ver el aviso más abajo.
// 429: el servicio push nos está limitando; hay que espaciar.
throw e;
}
}
}
Aunque el protocolo Web Push cifra la carga de extremo a extremo con las claves del cliente, hay dos razones de peso para ser austero. La primera es que la notificación se muestra en la pantalla de bloqueo del dispositivo, a la vista de cualquiera que pase por delante. «Marta te ha asignado una tarea» es aceptable; «Resultado de la analítica: positivo» no lo es en ningún caso. La segunda es que hay un límite práctico de tamaño de la carga, en torno a los 4 KB, y superarlo produce un error 413. El patrón correcto es enviar un identificador y un texto mínimo, y que el service worker recupere el detalle del servidor si le hace falta, aprovechando además que así el dato está actualizado en el momento de mostrarlo y no en el de enviarlo.
27.10.3 Suscripción desde Angular y permisos
import { Injectable, inject, signal } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })
export class PushService {
private readonly swPush = inject(SwPush);
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
readonly permiso = signal<NotificationPermission>(
'Notification' in window ? Notification.permission : 'denied',
);
constructor() {
// Clic en una notificación con la aplicación abierta o al abrirla.
this.swPush.notificationClicks
.pipe(takeUntilDestroyed())
.subscribe(({ notification }) => {
const url = notification.data?.url;
if (url) void this.router.navigateByUrl(url);
});
}
get disponible() {
return this.swPush.isEnabled;
}
async activar(): Promise<'ok' | 'denegado' | 'no_disponible'> {
if (!this.swPush.isEnabled) return 'no_disponible';
try {
const sub = await this.swPush.requestSubscription({
serverPublicKey: environment.vapidPublicKey,
});
await firstValueFrom(
this.http.post('/api/push/suscripciones', sub.toJSON()),
);
this.permiso.set('granted');
return 'ok';
} catch {
// requestSubscription rechaza si el usuario deniega el permiso.
this.permiso.set(Notification.permission);
return 'denegado';
}
}
async desactivar() {
const sub = await firstValueFrom(this.swPush.subscription);
if (!sub) return;
// Primero avisamos al servidor y luego cancelamos. Al revés,
// si falla la llamada, dejaríamos un canal muerto en la tabla.
await firstValueFrom(this.http.request(
'delete', '/api/push/suscripciones', { body: { endpoint: sub.endpoint } },
));
await sub.unsubscribe();
}
}
ngOnInit() {
// Pedir el permiso nada más entrar.
this.push.activar();
}
// Consecuencias reales:
// - Tasa de aceptación bajísima. El usuario
// no sabe aún qué es esta aplicación.
// - Una denegación es PERMANENTE: el
// navegador no vuelve a preguntar y el
// usuario tendría que ir a la
// configuración del sitio para revertirla.
// - Chrome penaliza a los sitios con muchas
// denegaciones mostrando un aviso todavía
// más discreto en el futuro.
// Has gastado tu única bala en el peor
// momento posible.
// 1. Nunca al entrar. Se ofrece en un momento
// con contexto: al asignarse una tarea con
// fecha límite, o en los ajustes.
// 2. Antes del permiso del navegador, un paso
// propio que explica QUÉ vas a enviar. Si
// el usuario dice que no aquí, no gastamos
// la petición real y podremos volver a
// preguntar más adelante.
async ofrecer() {
const quiere = await this.dialogos.confirmar({
titulo: '¿Te avisamos de las tareas urgentes?',
texto: 'Solo cuando te asignen una tarea con '
+ 'vencimiento en menos de 24 horas. '
+ 'Puedes desactivarlo cuando quieras.',
aceptar: 'Sí, avisadme',
cancelar: 'Ahora no',
});
if (!quiere) {
this.prefs.posponer('push', 30); // días
return;
}
const r = await this.push.activar();
if (r === 'denegado') this.mostrarComoRevertir();
}
27.10.4 Recibir y mostrar la notificación
Si escribes tu propio service worker, estos son los dos manejadores que necesitas. El de Angular ya los implementa, pero conviene saber qué hacen.
self.addEventListener('push', (event) => {
const datos = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(datos.titulo ?? 'TaskFlow', {
body: datos.cuerpo,
icon: '/assets/icons/icon-192.png',
badge: '/assets/icons/badge-72.png', // monocromo, para Android
// 'tag' agrupa: una notificación nueva con el mismo tag
// SUSTITUYE a la anterior en lugar de apilarse. Imprescindible
// para no inundar al usuario con veinte avisos del mismo hilo.
tag: datos.etiqueta ?? 'general',
renotify: false,
data: { url: datos.url },
actions: [
{ action: 'abrir', title: 'Ver tarea' },
{ action: 'hecha', title: 'Marcar como hecha' },
],
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const destino = event.notification.data?.url ?? '/';
event.waitUntil((async () => {
if (event.action === 'hecha') {
await fetch(`/api${destino}/completar`, { method: 'POST' });
return;
}
// Si ya hay una ventana de TaskFlow abierta, la reutilizamos y
// la enfocamos en lugar de abrir una pestaña más. Abrir siempre
// una pestaña nueva es un comportamiento que irrita mucho.
const clientes = await self.clients.matchAll({
type: 'window', includeUncontrolled: true,
});
const abierta = clientes.find((c) => c.url.includes(self.location.origin));
if (abierta) {
await abierta.focus();
abierta.postMessage({ tipo: 'navegar', url: destino });
} else {
await self.clients.openWindow(destino);
}
})());
});
27.11 Actualizar la aplicación sin romper nada
Esta sección resuelve el problema que anunciábamos en 27.4.3. Una aplicación cacheada tiene una propiedad incómoda: el usuario puede estar ejecutando la versión que instaló hace tres semanas mientras tu API ya va por la siguiente. Hay dos frentes que atender: avisar de que hay versión nueva y proteger el contrato con el servidor.
import { Injectable, inject, signal, ApplicationRef, DOCUMENT } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { concat, interval, first } from 'rxjs';
import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })
export class ActualizacionService {
private readonly updates = inject(SwUpdate);
private readonly appRef = inject(ApplicationRef);
private readonly doc = inject(DOCUMENT);
/** La interfaz muestra un aviso discreto cuando pasa a true. */
readonly hayVersionNueva = signal(false);
iniciar() {
if (!this.updates.isEnabled) return;
this.comprobarPeriodicamente();
this.updates.versionUpdates
.pipe(
filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'),
takeUntilDestroyed(),
)
.subscribe(() => this.hayVersionNueva.set(true));
// Fallo irrecuperable: el service worker no puede seguir
// sirviendo esta versión. Recargar es la única salida.
this.updates.unrecoverable
.pipe(takeUntilDestroyed())
.subscribe(() => {
this.doc.defaultView!.location.reload();
});
}
/** Llamado desde el botón «Actualizar» del aviso. */
async aplicar() {
await this.updates.activateUpdate();
this.doc.defaultView!.location.reload();
}
private comprobarPeriodicamente() {
// El detalle importante: esperar a que la aplicación esté
// ESTABLE antes de arrancar el intervalo. Un setInterval que
// empieza en el arranque mantiene a Angular con tareas
// pendientes para siempre y, con Zone.js, hace que isStable
// no emita nunca. Eso rompe el renderizado del lado del
// servidor y las pruebas de extremo a extremo, que esperan
// a la estabilidad.
const estable$ = this.appRef.isStable.pipe(first((e) => e === true));
const cada6h$ = interval(6 * 60 * 60 * 1000);
concat(estable$, cada6h$)
.pipe(takeUntilDestroyed())
.subscribe(() => void this.updates.checkForUpdate());
}
}
Hay una decisión de producto detrás de este código. Si el usuario está a mitad de escribir una descripción larga y recargas la página, pierde el texto y te odia. La regla razonable es: avisar siempre de forma discreta y no invasiva, aplicar automáticamente solo si no hay trabajo sin guardar y la pestaña lleva un rato en segundo plano, y forzar únicamente cuando el cambio sea crítico, por ejemplo un parche de seguridad o una incompatibilidad con la API. Para ese último caso conviene tener un canal explícito: una bandera en la respuesta del servidor que la aplicación consulte y que active el modo de actualización obligatoria.
27.11.1 Proteger el contrato con el servidor
El cliente cacheado es el argumento definitivo a favor del versionado de API que veías en el capítulo 25. Una PWA convierte un problema teórico en uno cotidiano: tendrás clientes de versiones antiguas hablando con tu servidor durante semanas.
export const versionInterceptor: HttpInterceptorFn = (req, next) => {
const actualizacion = inject(ActualizacionService);
// Cada petición anuncia con qué versión del cliente se hizo.
const conVersion = req.clone({
setHeaders: { 'X-Cliente-Version': environment.version },
});
return next(conVersion).pipe(
tap((evento) => {
if (evento instanceof HttpResponse) {
// El servidor puede responder que este cliente ya no vale.
if (evento.headers.get('X-Cliente-Obsoleto') === 'true') {
actualizacion.exigirActualizacion();
}
}
}),
catchError((e: HttpErrorResponse) => {
// 426 Upgrade Required: el servidor rechaza esta versión.
if (e.status === 426) actualizacion.exigirActualizacion();
return throwError(() => e);
}),
);
};
27.12 Seguridad y privacidad
| Riesgo | Por qué existe | Mitigación |
|---|---|---|
| Datos de un usuario visibles para el siguiente | Un ordenador compartido. Al cerrar sesión, las tareas cacheadas siguen en IndexedDB y en la Cache Storage. | Borrar todo el almacenamiento en el cierre de sesión: caches.keys() y borrar, indexedDB.deleteDatabase(), y desregistrar la suscripción push. Hazlo en un único servicio de cierre de sesión para que nadie olvide un paso. |
| Token de acceso cacheado | Si una respuesta de la API que contiene el token acaba en la caché, queda escrito en disco sin cifrar. | Nunca cachear rutas de autenticación. Excluir /api/auth/** del service worker de forma explícita. |
| Service worker malicioso persistente | Una vulnerabilidad de scripting entre sitios podría registrar un service worker propio, que sobreviviría al cierre del navegador y controlaría todo el origen. | Una política de seguridad de contenido con worker-src 'self', sanear toda entrada, y el interruptor de emergencia de 27.4.4. |
| Datos de dominio en el disco de un portátil robado | IndexedDB no está cifrada. | Decidir qué se cachea. Los datos verdaderamente sensibles no se guardan sin conexión, y punto. Es una decisión de negocio, no técnica. |
| Notificaciones que revelan información | Aparecen en la pantalla de bloqueo. | Cargas mínimas y genéricas, como se explicó en 27.10.2. |
| Punto de entrega push filtrado | Con el punto de entrega y las claves, un tercero podría intentar enviar notificaciones. | VAPID lo impide, porque el servicio push verifica la firma. Aun así, trata la tabla de suscripciones como datos personales. |
async cerrarSesion() {
// 1. Avisar al servidor mientras todavía tenemos credenciales.
await firstValueFrom(this.http.post('/api/auth/logout', {})).catch(() => {});
// 2. Cancelar la suscripción push de este dispositivo.
await this.push.desactivar().catch(() => {});
// 3. Borrar toda la Cache Storage.
if ('caches' in window) {
const nombres = await caches.keys();
await Promise.all(nombres.map((n) => caches.delete(n)));
}
// 4. Borrar las bases de datos de IndexedDB.
// Cerrar la conexión primero: deleteDatabase se queda bloqueado
// indefinidamente si hay una conexión abierta, y sin timeout.
await cerrarDb();
await new Promise<void>((resolver) => {
const req = indexedDB.deleteDatabase('taskflow');
req.onsuccess = req.onerror = req.onblocked = () => resolver();
});
// 5. Almacenamiento síncrono.
localStorage.clear();
sessionStorage.clear();
// 6. Recarga completa: garantiza que no queda estado en memoria
// de la sesión anterior en ningún servicio de Angular.
location.href = '/login';
}
27.13 Cómo se prueba y se depura todo esto
Un service worker es difícil de probar precisamente por lo que lo hace útil: persiste, se ejecuta fuera de la página y tiene un ciclo de vida propio. Estas son las herramientas, ordenadas de más barata a más cara.
| Técnica | Qué comprueba | Coste |
|---|---|---|
| Pestaña «Application» de las herramientas de desarrollo | Estado del registro, contenido de las cachés y de IndexedDB, envío de un push de prueba, casillas de Offline, Update on reload y Bypass for network. | Nulo. Es el primer sitio al que ir siempre. |
| Limitación de red | Comportamiento con red lenta, que es más realista que la desconexión total. Prueba con el perfil de 3G lenta, no solo con «offline». | Nulo. |
| Modo avión de verdad en un móvil real | Lo único que revela el comportamiento real del sistema operativo, la pantalla de arranque y el icono. | Bajo. Imprescindible antes de dar por buena una PWA. |
| Pruebas unitarias de la lógica de la cola | Orden de vaciado, retroceso exponencial, tratamiento del mensaje envenenado, resolución de conflictos. Es lógica pura y se prueba sin navegador con fake-indexeddb. | Bajo y muy rentable: aquí está el 90 % de tus errores futuros. |
| Pruebas de extremo a extremo con interceptación de red | El flujo completo. Playwright permite cortar la red del contexto con context.setOffline(true). | Medio. |
| Auditoría de Lighthouse | Requisitos de instalabilidad y del manifiesto. | Nulo, pero recuerda que aprobar la auditoría no significa que tu modo sin conexión funcione. |
import 'fake-indexeddb/auto'; // sustituye indexedDB por una en memoria
describe('ColaService', () => {
let cola: ColaService;
let fetchMock: jest.Mock;
beforeEach(async () => {
indexedDB = new IDBFactory(); // base limpia en cada prueba
fetchMock = jest.fn();
global.fetch = fetchMock;
cola = TestBed.inject(ColaService);
});
it('vacía la cola en orden estricto de llegada', async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
await cola.encolar(crearOp('POST', '/api/tareas'));
await cola.encolar(crearOp('PATCH', '/api/tareas/1'));
await cola.vaciar();
const urls = fetchMock.mock.calls.map((c) => c[0]);
expect(urls).toEqual(['/api/tareas', '/api/tareas/1']);
expect(cola.pendientes()).toBe(0);
});
it('descarta la operación envenenada sin bloquear las siguientes', async () => {
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 422 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
await cola.encolar(crearOp('POST', '/api/tareas')); // fallará
await cola.encolar(crearOp('POST', '/api/proyectos')); // debe pasar
await cola.vaciar();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(cola.pendientes()).toBe(0);
});
it('para al primer error de red y conserva el resto', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
await cola.encolar(crearOp('POST', '/api/tareas'));
await cola.encolar(crearOp('POST', '/api/proyectos'));
await cola.vaciar();
// Solo se intentó la primera: parar preserva el orden causal.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(cola.pendientes()).toBe(2);
});
});
import { test, expect } from '@playwright/test';
test('crea una tarea sin conexión y se sincroniza al volver', async ({ page, context }) => {
await page.goto('/tareas');
// Esperar a que el service worker controle la página; sin esto la
// prueba es intermitente porque la primera carga no está controlada.
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload();
await context.setOffline(true);
await expect(page.getByRole('status')).toContainText('Sin conexión');
await page.getByRole('button', { name: 'Nueva tarea' }).click();
await page.getByLabel('Título').fill('Revisar el informe trimestral');
await page.getByRole('button', { name: 'Guardar' }).click();
// Aparece al instante, marcada como pendiente.
const fila = page.getByRole('row', { name: /Revisar el informe/ });
await expect(fila).toBeVisible();
await expect(fila.getByTestId('pendiente-subir')).toBeVisible();
await context.setOffline(false);
await expect(fila.getByTestId('pendiente-subir')).toBeHidden({ timeout: 15_000 });
// Y sobrevive a una recarga completa: está en el servidor.
await page.reload();
await expect(page.getByRole('row', { name: /Revisar el informe/ })).toBeVisible();
});
27.14 Cuándo no hacer nada de esto
Un capítulo honesto tiene que incluir esta sección. La capacidad de trabajar sin conexión es cara de construir y, sobre todo, cara de mantener: cada campo nuevo de una entidad hay que pensarlo dos veces, cada cambio de la API hay que evaluarlo contra clientes antiguos, y cada error se reproduce mal porque depende de un estado local que no ves.
No merece la pena si
- Tu aplicación es un panel interno que solo se usa desde ordenadores de oficina con red cableada.
- Los datos son intrínsecamente colaborativos y en tiempo real: mostrar una versión de hace veinte minutos es peor que no mostrar nada.
- Cada operación exige validación del servidor que no puedes replicar en el cliente, como comprobar existencias o aplicar precios.
- El sector es muy regulado y guardar datos en el dispositivo del usuario tiene implicaciones legales.
- El equipo no tiene capacidad para mantener la complejidad añadida. Una funcionalidad sin conexión medio hecha es peor que ninguna, porque genera confianza que luego se traiciona.
Sí merece la pena si
- Hay usuarios de campo: técnicos, repartidores, inspectores, personal sanitario a domicilio.
- El uso es mayoritariamente móvil y en movimiento.
- La lectura predomina claramente sobre la escritura, que es el caso barato y agradecido.
- El trabajo del usuario es largo y perderlo por un fallo de red resulta inaceptable.
- Compites con una aplicación nativa y quieres la sensación de aplicación sin pagar dos desarrollos.
No abordes esto como un proyecto único de tres meses. Ve por escalones y para en el que te dé el retorno que buscas. Escalón 1: precachear el armazón y poner una página de respaldo. Es medio día de trabajo y elimina la pantalla del dinosaurio. Escalón 2: cachear las lecturas de la API con la estrategia adecuada e indicar en la interfaz cuándo un dato viene del disco. Es una semana. Escalón 3: manifiesto, instalación y actualizaciones controladas. Otra semana. Escalón 4: cola de escrituras con idempotencia y resolución de conflictos. Aquí ya hablamos de un mes largo y de un compromiso permanente de mantenimiento. La mayoría de los productos obtienen el 80 % del valor percibido en los tres primeros escalones.
27.15 Errores comunes y cómo solucionarlos
| Síntoma | Causa real | Solución |
|---|---|---|
| «Mi service worker no se actualiza nunca» | El contenido del fichero no ha cambiado, o el servidor lo está sirviendo desde caché HTTP. | Incrustar una versión o un hash en el fichero, y servirlo con Cache-Control: no-cache. Comprueba en la pestaña Network que la petición al fichero devuelve 200 y no 304 desde caché de disco. |
| El usuario ve la versión antigua tras desplegar | El service worker nuevo está en estado de espera porque hay una pestaña abierta. | Es el comportamiento correcto. Implementa el aviso de versión nueva de 27.11. Recargar no basta: hay que cerrar todas las pestañas del origen. |
| Pantalla en blanco tras desplegar | Se llamó a skipWaiting() y la pestaña antigua pidió un fragmento con hash que ya no existe. | No saltar la espera automáticamente. Mantener accesibles las versiones anteriores de los ficheros durante unos días tras el despliegue. |
Unexpected token < in JSON solo en producción | navigationUrls no excluye la API y el service worker devuelve index.html para una llamada de datos. | Añadir "!/**/api/**" a navigationUrls. |
Response body is already used | Se metió la respuesta en la caché sin clonarla. | cache.put(req, respuesta.clone()), y clonar siempre antes de consumir el cuerpo. |
| El service worker no instala y no dice por qué | Una sola URL de la lista de addAll devuelve algo distinto de 2xx. La operación es atómica. | Revisar la pestaña Network filtrando por el service worker. Si algún recurso es opcional, cachearlo por separado con su propio catch. |
Estado EXISTING_CLIENTS_ONLY en /ngsw/state | Un hash de ngsw.json no coincide con el fichero descargado: despliegue no atómico o CDN sirviendo dos versiones a la vez. | Despliegue atómico, o purgar la CDN de forma coordinada tras subir todos los ficheros. |
Todo funciona en ng serve y nada en producción | El service worker está desactivado en desarrollo. Nunca lo has probado de verdad. | Construir en modo producción y servir con npx http-server dist/... -p 8080. Es el único modo de probarlo en local. |
| La cola de escrituras nunca se vacía y el contador sube | Una operación falla con un 4xx permanente y se reintenta eternamente, bloqueando a las siguientes. | Descartar los 4xx que no sean 408 ni 429, apartarlos a una zona de fallidos y avisar al usuario. |
| Se crean tareas duplicadas al recuperar la conexión | La misma petición se envió dos veces: una llegó al servidor y la respuesta se perdió. | Clave de idempotencia por operación, más UUID generado en el cliente como clave primaria. |
| La aplicación dice que hay conexión y todas las peticiones fallan | navigator.onLine devuelve true con un portal cautivo o un router sin salida. | Verificar contra un punto real del servidor, como en 27.9.3. |
| IndexedDB se queda colgada al abrir | Otra pestaña con la versión anterior del esquema bloquea la actualización. | Implementar blocked y blocking, y avisar al usuario de que cierre las demás pestañas. |
| Datos que reaparecen tras cerrar sesión | La limpieza olvidó la Cache Storage o IndexedDB. | Un único servicio de cierre de sesión que borre los cinco almacenes, como en 27.12. |
| Los push dejan de llegar a algunos usuarios | Sus suscripciones caducaron y el servidor devuelve 404 o 410 sin que nadie las borre. | Eliminar la suscripción al recibir esos códigos, y ofrecer al usuario volver a activarlas. |
| Error 413 al enviar un push | La carga supera el límite del servicio push, en torno a 4 KB. | Enviar un identificador y un texto corto; el detalle se recupera después. |
| El icono de la aplicación aparece cortado en Android | Falta un icono con purpose: "maskable". | Generar uno con zona de seguridad central y declararlo en el manifiesto. |
| Destello de color al abrir la aplicación instalada | background_color del manifiesto no coincide con el fondo real. | Igualar ambos valores. |
| Las pruebas de extremo a extremo fallan de forma intermitente | La primera carga no está controlada por el service worker y la prueba no espera a que lo esté. | Esperar a navigator.serviceWorker.ready y recargar antes de empezar. |
| La aplicación nunca alcanza el estado estable y el renderizado del servidor se cuelga | Un setInterval de comprobación de actualizaciones arrancado en el constructor. | Encadenarlo después de appRef.isStable, como en 27.11. |
Una variable global del service worker vale undefined a ratos | El navegador detuvo el proceso por inactividad y lo revivió. | Todo estado que deba sobrevivir va a IndexedDB. |
27.16 Buenas y malas prácticas
Buenas prácticas
- Empieza por el escalón más barato: armazón precacheado y página de respaldo. Mide si aporta antes de seguir.
- Elige la estrategia de caché por tipo de recurso, no una única para toda la aplicación.
- Marca visualmente los datos que vienen del disco, con su antigüedad.
- Genera los identificadores en el cliente con UUID y acéptalos en el servidor.
- Toda operación encolada lleva clave de idempotencia.
- Vacía la cola en orden estricto y para al primer fallo de red.
- Aparta las operaciones envenenadas y avisa de cuáles no se pudieron guardar.
- Verifica la conexión contra tu servidor, no contra
navigator.onLine. - Avisa de la versión nueva y deja decidir al usuario cuándo aplicarla.
- Pide el permiso de notificaciones con contexto y tras un paso previo propio.
- Borra las suscripciones push que devuelven 404 o 410.
- Limpia todos los almacenes al cerrar sesión, desde un único sitio.
- Prueba en un móvil real en modo avión antes de dar nada por bueno.
- Ten preparado el service worker de emergencia desde el primer despliegue.
- Prueba la lógica de la cola con pruebas unitarias: es donde vivirán tus errores.
- Antes de escribir un service worker, comprueba si unas cabeceras HTTP bien puestas resuelven tu caso.
Malas prácticas
- Llamar a
skipWaiting()para «arreglar» que no se actualiza. - Cachear con la estrategia de caché primero recursos sin hash en el nombre.
- Cachear respuestas de error o respuestas opacas de otro origen.
- Guardar tokens o datos de dominio en
localStorage. - Suponer que lo guardado en el cliente seguirá ahí mañana.
- Reintentar eternamente una operación que falla con 422.
- Paralelizar el vaciado de la cola y romper el orden causal.
- Sobrescribir sin comprobar la versión y perder el trabajo de otro en silencio.
- Pedir el permiso de notificaciones en el primer segundo de la primera visita.
- Meter datos personales o clínicos en la carga de un push.
- Llamar a
respondWithpara todas las peticiones «por si acaso». - Olvidar excluir la API de
navigationUrls. - Guardar estado en variables globales del service worker.
- Probar el modo sin conexión solo con la casilla «Offline» del escritorio.
- Prometer escritura sin conexión sin haber diseñado antes la política de conflictos.
- Servir el fichero del service worker con una caché larga.
27.17 Preguntas frecuentes
¿Un service worker se ejecuta en un Web Worker?
¿Por qué mi service worker nuevo no toma el control aunque recargue la página?
activateUpdate() seguido de recarga, que es lo que debe hacer una aplicación de producción.¿Cuál es la diferencia real entre la Cache Storage y la caché HTTP del navegador?
fetch hacia la red puede además ser servida por la caché HTTP. Un consejo práctico derivado de esto es que, si tu necesidad se cubre con cabeceras bien puestas, no añadas un service worker: es menos código y menos ciclo de vida que gestionar.¿Puedo usar MikroORM en el cliente para la base de datos local?
idb o Dexie si prefieres una API más rica. Lo que sí puedes, y merece la pena, es compartir entre cliente y servidor los tipos de TypeScript y los esquemas de validación, de forma que el modelo local y el remoto no se separen con el tiempo. Existen soluciones de replicación como RxDB o PouchDB que sincronizan automáticamente, pero imponen su propio modelo de datos y su formato de almacenamiento, así que son una decisión de arquitectura mayor y no un detalle de implementación.¿Cuánto puedo almacenar realmente?
navigator.storage.estimate(), solicitar persistencia y, sobre todo, diseñar para que perder el almacenamiento no destruya nada.¿Las notificaciones push funcionan en iOS?
¿Debo usar el service worker de Angular o Workbox?
SwUpdate y SwPush ya hechos. Workbox cuando necesites lógica por petición, sincronización en segundo plano integrada, rutas con expresiones regulares o mezclar estrategias que la configuración declarativa no permite. Un consejo pragmático: empieza con el de Angular y cambia solo cuando choques con un límite real, no por si acaso.¿Cómo pruebo el modo sin conexión si en desarrollo el service worker está desactivado?
ng build && npx http-server dist/taskflow/browser -p 8080. Después abres localhost:8080, que se considera contexto seguro, y ya tienes el service worker activo. Activar el service worker en ng serve es mala idea porque compite con la recarga en caliente y te hará perder horas persiguiendo cambios que no aparecen.¿Qué pasa si el usuario tiene la aplicación abierta cuando despliego una versión con un cambio rompedor en la API?
¿Merece la pena una PWA si ya tengo una aplicación nativa?
¿Cómo evito que un usuario en un ordenador compartido vea los datos del anterior?
localStorage, sessionStorage y la suscripción push, y debe hacerse desde un único servicio para que nadie añada un almacén nuevo y olvide incluirlo. Añade además una recarga completa al final para descartar el estado en memoria de los servicios de Angular. Y considera detectar el escenario de equipo compartido para reducir lo que se guarda.¿Cómo se comporta un service worker con el renderizado del lado del servidor?
navigator, caches o window no debe ejecutarse en el servidor: protégelo comprobando la plataforma o inyectando DOCUMENT en lugar de usar los globales. La segunda es la de la comprobación periódica de actualizaciones: un temporizador arrancado en un constructor impide que la aplicación alcance el estado estable, y el renderizado del servidor espera precisamente a ese estado antes de serializar el HTML.¿Puedo cachear peticiones POST?
Request con método GET. Y tiene sentido, porque un POST no es idempotente y guardar su respuesta para reutilizarla sería incorrecto por definición. Si tu API usa POST para consultas con cuerpos grandes, cosa que ocurre en búsquedas complejas, tienes dos caminos: calcular una clave a partir del cuerpo y guardar el resultado en IndexedDB con tu propia política, o replantear el diseño para que la consulta sea un GET con parámetros, que es lo que recomienda el capítulo 25 siempre que quepa en la URL.¿Cómo depuro un service worker que ya está en producción en el móvil de un cliente?
/ngsw/state, que puedes pedirle al usuario que abra y te envíe. Después, la depuración remota: un Android se conecta por USB y se inspecciona desde chrome://inspect, y un iPhone se inspecciona desde el menú Desarrollo de Safari en un Mac. Como red de seguridad, conviene registrar en tu sistema de observabilidad la versión del service worker y del cliente en cada petición: cuando alguien reporta un problema, saber que va con la versión de hace tres semanas suele resolver la incidencia en un minuto.27.18 Ejercicios
- Añade
@angular/pwaa TaskFlow, construye en modo producción, sírvelo en local y comprueba en la pestaña Application que el service worker está activo. Documenta el estado que devuelve/ngsw/state. - Crea una página de respaldo sin conexión con la identidad visual de la aplicación y verifica que se muestra al navegar con la red cortada.
- Escribe el manifiesto completo de TaskFlow, incluyendo un icono maskable y dos accesos directos. Instala la aplicación en un móvil real y comprueba que el icono no aparece recortado y que no hay destello de color al abrir.
- Configura un
dataGroupcon estrategia de frescura para/api/tareasy otro de rendimiento para/api/etiquetas. Demuestra con la pestaña Network que se comportan de forma distinta con la red lenta.
- Implementa el servicio de detección de conexión de 27.9.3 con un punto
/api/saluden NestJS que responda aHEADsin tocar la base de datos, y muestra una barra de estado en la interfaz. - Implementa el flujo completo de aviso de versión nueva con
SwUpdate: aviso discreto, botón de actualizar y recarga. Añade la condición de no ofrecerlo si hay un formulario con cambios sin guardar. - Monta el almacén de IndexedDB de 27.8.1 con
idby escribe una migración de la versión 1 a la 2 que añada un índice nuevo, comprobando que funciona sobre una base ya existente. - Implementa las notificaciones push de extremo a extremo: entidad y controlador en NestJS, suscripción en Angular con el paso previo de explicación, y envío al asignar una tarea. Comprueba que al recibir un 410 la suscripción se borra.
- Escribe una prueba de Playwright que verifique que la aplicación muestra la página de respaldo con la red cortada, esperando correctamente a que el service worker controle la página.
- Implementa la cola de sincronización completa: escritura optimista en IndexedDB, UUID generado en el cliente, clave de idempotencia, vaciado en orden con parada al primer fallo de red, retroceso exponencial y zona de operaciones fallidas.
- Añade al servidor el bloqueo optimista con
@Property({ version: true })de MikroORM y devuelve un 409 con el estado actual. En el cliente, construye la interfaz de resolución de conflictos con las tres salidas descritas en 27.9.4. - Implementa la idempotencia en NestJS: un interceptor que lea la cabecera
Idempotency-Key, guarde el resultado de la primera ejecución y devuelva la misma respuesta ante un reenvío, con una ventana de validez de 24 horas. - Añade sincronización en segundo plano con degradación: si la API no está disponible, la cola debe seguir funcionando con la aplicación abierta sin ningún cambio de comportamiento visible.
- Escribe la batería de pruebas unitarias de la cola con
fake-indexeddb, cubriendo como mínimo el orden, la operación envenenada, la parada por fallo de red, el conflicto 409 y el reenvío idempotente. - Implementa el interruptor de emergencia y ensáyalo: despliega a propósito un service worker roto, comprueba que la aplicación queda inutilizable y recupérala desplegando el de emergencia. Documenta el procedimiento como una guía de actuación para tu equipo.
Solución comentada del ejercicio 12: interceptor de idempotencia en NestJS
@Injectable()
export class IdempotenciaInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly em: EntityManager,
) {}
async intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest<Request>();
const clave = req.header('Idempotency-Key');
// Solo aplica a métodos que crean o modifican.
if (!clave || req.method === 'GET') return next.handle();
const usuarioId = (req as any).user?.id ?? 'anonimo';
// La clave se ata al usuario y a la ruta: dos clientes distintos
// no pueden colisionar, ni siquiera generando el mismo uuid.
const k = `idem:${usuarioId}:${req.method}:${req.path}:${clave}`;
const guardado = await this.cache.get<RegistroIdem>(k);
if (guardado) {
// Caso 1: la misma clave con un cuerpo distinto. Es un error
// del cliente, no un reenvío. Devolver la respuesta anterior
// sería incorrecto y confuso.
if (guardado.huella !== huellaDe(req.body)) {
throw new UnprocessableEntityException(
'La clave de idempotencia ya se usó con un cuerpo distinto',
);
}
// Caso 2: la primera ejecución sigue en curso. El cliente
// reintentó demasiado pronto. 409 y que espere.
if (guardado.estado === 'en_curso') {
throw new ConflictException('Petición en curso, reinténtalo');
}
// Caso 3: reenvío legítimo. Devolvemos el resultado anterior
// sin volver a ejecutar nada.
return of(guardado.respuesta);
}
// Marcamos la clave ANTES de ejecutar, para que un reenvío
// simultáneo caiga en el caso 2 y no ejecute dos veces.
await this.cache.set(k, {
estado: 'en_curso', huella: huellaDe(req.body),
}, 24 * 3600 * 1000);
return next.handle().pipe(
tap(async (respuesta) => {
await this.cache.set(k, {
estado: 'hecho', huella: huellaDe(req.body), respuesta,
}, 24 * 3600 * 1000);
}),
catchError(async (e) => {
// Si falló, liberamos la clave: el cliente debe poder
// reintentar la misma operación.
await this.cache.del(k);
throw e;
}),
);
}
}
function huellaDe(cuerpo: unknown): string {
return createHash('sha256')
.update(JSON.stringify(cuerpo ?? null))
.digest('hex');
}
Tres decisiones merecen comentario. La marca en_curso se escribe antes de ejecutar para cerrar la ventana de carrera en la que dos reenvíos simultáneos ejecutarían la operación dos veces; sin ella, la idempotencia solo funciona con reenvíos separados en el tiempo. La comparación de huellas distingue un reenvío legítimo de un error del cliente que reutiliza la clave para otra cosa, y responder 422 en ese caso es más honesto que devolver una respuesta que no corresponde. Y liberar la clave cuando la operación falla es imprescindible: si la conserváramos, el cliente no podría reintentar nunca esa operación, ni siquiera después de corregir la causa del fallo. Para un entorno con varias instancias, la caché debe ser compartida, es decir Redis y no memoria local.
Solución comentada del ejercicio 10: retroceso exponencial con perturbación aleatoria
/**
* Calcula cuánto esperar antes del siguiente intento.
*
* Base exponencial: 1 s, 2 s, 4 s, 8 s... con un techo, para que un
* fallo prolongado no derive en esperas de horas.
*
* La perturbación aleatoria es la parte que suele faltar y la que de
* verdad importa: si mil clientes pierden la conexión a la vez porque
* se cayó tu red, y todos calculan exactamente el mismo retraso,
* todos reintentarán en el mismo milisegundo y tumbarán el servidor
* justo cuando acaba de levantarse. Repartir los reintentos en una
* ventana aleatoria evita ese efecto de manada.
*/
export function retrasoDeReintento(intentos: number): number {
const BASE_MS = 1000;
const TECHO_MS = 5 * 60 * 1000; // 5 minutos
const exponencial = Math.min(BASE_MS * 2 ** intentos, TECHO_MS);
// Perturbación completa: un valor uniforme entre 0 y el exponencial.
// Es más agresiva que la parcial y, según el análisis clásico de
// este problema, la que mejor reparte la carga.
return Math.random() * exponencial;
}
export function debeRendirse(op: OperacionPendiente): boolean {
const MAX_INTENTOS = 12;
const MAX_EDAD_MS = 7 * 24 * 3600 * 1000; // una semana
// Rendirse por edad además de por intentos: una operación de hace
// una semana probablemente ya no tenga sentido de negocio, y
// aplicarla podría revertir cambios más recientes de otra persona.
return op.intentos >= MAX_INTENTOS
|| Date.now() - op.creadaEn > MAX_EDAD_MS;
}
El límite por antigüedad es la parte que más se olvida y la que más daño evita. Imagina a un usuario que dejó una pestaña abierta antes de irse de vacaciones, con una edición encolada. Al volver, esa edición se envía y sobrescribe dos semanas de cambios de sus compañeros. Rendirse por edad, avisar al usuario y dejarle decidir es infinitamente preferible a aplicar ciegamente una intención de hace quince días.
Solución comentada del ejercicio 6: aviso de versión con protección de trabajo sin guardar
/**
* Registro central de formularios con cambios sin guardar.
* Cada formulario se apunta al entrar y se borra al salir o guardar.
*/
@Injectable({ providedIn: 'root' })
export class TrabajoSucioService {
private readonly sucios = new Set<string>();
readonly haySucio = signal(false);
marcar(id: string, sucio: boolean) {
sucio ? this.sucios.add(id) : this.sucios.delete(id);
this.haySucio.set(this.sucios.size > 0);
}
}
@Component({
selector: 'tf-aviso-version',
template: `
@if (visible()) {
<div class="aviso" role="status">
<span>Hay una versión nueva de TaskFlow disponible.</span>
<button type="button" (click)="actualizar()">Actualizar ahora</button>
<button type="button" (click)="posponer()">Más tarde</button>
</div>
}
`,
})
export class AvisoVersionComponent {
private readonly act = inject(ActualizacionService);
private readonly sucio = inject(TrabajoSucioService);
private readonly pospuesto = signal(false);
// El aviso solo aparece si hay versión nueva, el usuario no lo ha
// pospuesto y no hay nada a medio escribir. Interrumpir a alguien
// que está redactando es la forma más rápida de que pulse
// «Más tarde» sin leer y no vuelva a hacer caso nunca.
readonly visible = computed(() =>
this.act.hayVersionNueva() && !this.pospuesto() && !this.sucio(),
);
async actualizar() { await this.act.aplicar(); }
posponer() {
this.pospuesto.set(true);
// Vuelve a ofrecerlo en media hora, no en la siguiente sesión:
// el objetivo es que actualice, no dejar de molestar para siempre.
setTimeout(() => this.pospuesto.set(false), 30 * 60 * 1000);
}
}
El componente encapsula una regla de producto en una sola línea de computed, y ese es el valor de tener el estado de «trabajo sin guardar» centralizado en lugar de disperso por los formularios: la misma señal sirve para el aviso de actualización, para el guardia de ruta que evita salir de un formulario a medias y para el manejador de beforeunload.
27.19 Resumen del capítulo
- Un service worker es un proxy programable entre tu aplicación y la red, ejecutándose en su propio hilo, sin DOM, sin estado en memoria fiable y con un ciclo de vida propio. Ese ciclo de vida, y no la caché, es la fuente de casi todos los problemas.
- Un service worker nuevo se instala pero espera a que se cierren todas las pestañas con la versión anterior. Es una garantía de coherencia, no un fallo, y saltársela con
skipWaiting()sin más produce pantallas en blanco. - Hay cinco estrategias de caché y la elección se hace por recurso, no por aplicación: caché primero para lo versionado por hash, red primero con tope de tiempo para los datos que cambian, obsoleto mientras revalida para catálogos, y solo red para todo lo sensible y para las escrituras.
- Angular ofrece un service worker completo gobernado por
ngsw-config.json. Cubre la mayoría de los casos, pero no cachea escrituras ni admite lógica por petición. Excluir la API denavigationUrlsno es opcional. - El manifiesto es barato y aporta mucho: nombre corto real, iconos maskable, color de fondo coherente y accesos directos.
- Para datos estructurados, IndexedDB con una envoltura como
idb. NuncalocalStoragepara datos de dominio ni para tokens. - El almacenamiento del cliente puede desaparecer. El servidor es la fuente de verdad y el cliente, una caché.
- Escribir sin conexión exige cuatro piezas: identificadores generados en el cliente, cola persistente en orden, claves de idempotencia y una política explícita de conflictos. Sin las cuatro, el resultado es pérdida silenciosa de datos.
- La operación envenenada, el lie-fi y
navigator.onLinemintiendo son los tres modos de fallo que hay que anticipar desde el diseño. - Las notificaciones push atraviesan un servicio del fabricante del navegador, se firman con VAPID, no deben llevar datos sensibles y exigen borrar las suscripciones que devuelven 404 o 410.
- Una aplicación cacheada convierte el versionado de API en una necesidad diaria, no en un lujo teórico.
- Todo esto tiene un coste de mantenimiento permanente. Sube por escalones y detente cuando dejes de obtener retorno: los tres primeros suelen dar casi todo el valor.
27.20 Recursos adicionales
- MDN · API de Service Workers — la referencia normativa, con el ciclo de vida explicado paso a paso.
- MDN · IndexedDB — incluye el detalle de transacciones y migraciones.
- MDN · Aplicaciones web progresivas — guía completa y actualizada, incluido el manifiesto.
- angular.dev · Service workers y PWA — documentación oficial de
ngsw-config.json,SwUpdateySwPush. - Workbox — la alternativa cuando la configuración declarativa de Angular se queda corta.
- web.dev · Learn PWA — curso estructurado y gratuito, con capítulos específicos sobre almacenamiento y sincronización.
- idb — la envoltura de IndexedDB con promesas y tipos de TypeScript.
- web-push — la biblioteca de Node.js usada en el servicio de NestJS de este capítulo.
- RFC 8030 · Generic Event Delivery Using HTTP Push — el protocolo que hay debajo de Web Push.
- RFC 8292 · VAPID — cómo se identifica el servidor de aplicación ante el servicio push.
- web.dev · Almacenamiento en la web — cuotas, persistencia y política de expulsión, con datos por navegador.
- maskable.app — herramienta para comprobar cómo se recortará tu icono en los distintos lanzadores.
Este capítulo se apoya en el 25 para la idempotencia, la caché HTTP y el versionado de API; en el 17 para el bloqueo optimista con la columna de versión de MikroORM; en el 12 para el almacenamiento seguro de credenciales y la política de seguridad de contenido; en el 11 para el patrón de cola con mensajes fallidos, aquí trasladado al cliente; y en el 7 para el renderizado del lado del servidor, con el que el service worker convive sin conflicto si respetas las dos precauciones descritas. El capítulo 28 continúa por el otro extremo del sistema: qué ocurre en el proceso de Node.js cuando la carga sube.