Service Worker: el singleton que no puede alojar tu base
El Service Worker es el único singleton por origen con soporte universal, pero su ciclo de vida orientado a eventos lo descalifica como sede de una base de datos y lo convierte en excelente para casi todo lo demás.
La conclusión de la lección anterior deja un hueco muy concreto: hace falta una instancia única por origen y el worker compartido no está en todas partes. Existe, sin embargo, otra pieza que sí lo está, que también es única por origen y que además controla todas las pestañas a la vez. La comparación se hace sola y la tentación es inmediata: si el Service Worker es un singleton con soporte universal, ¿por qué no poner ahí la base de datos y terminar con el asunto? La respuesta es que su unicidad es real pero su permanencia no lo es, y esa diferencia no es un matiz: es la propiedad exacta que una base de datos necesita y la única que este contexto no ofrece. Entenderlo bien deja además claro dónde encaja de verdad, que es en más sitios de los que suele aprovecharse.
- Situar al Service Worker como proxy de red y singleton de ámbito, no como servidor de aplicación.
- Comprender su ciclo de vida orientado a eventos y por qué el estado en memoria no sobrevive.
- Enumerar razones concretas por las que alojar ahí una base de datos produce fallos difíciles de diagnosticar.
- Identificar las tareas donde sí aporta valor, incluida una que ninguna otra pieza puede hacer.
Un singleton por origen que además intercepta la red
El Service Worker se registra una vez, vive en un ámbito determinado por su ruta y, a partir de ese momento, todas las páginas de ese ámbito quedan bajo su control. Puede interceptar las peticiones de red antes de que salgan, responder desde una caché, fabricar respuestas sintéticas, y hablar con cada cliente mediante mensajes. La lista de clientes se consulta desde dentro, cosa que ni el canal de difusión ni el worker compartido ofrecen con ese detalle.
// Dentro del Service Worker: sabe quien esta abierto y puede hablar con cada uno
const clientes = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
for (const c of clientes) {
c.postMessage({ tipo: 'aviso', url: c.url, visible: c.visibilityState });
}
Hay una capacidad suya que conviene destacar ya, porque será la pieza clave de la lección siguiente: el mensaje dirigido a un cliente admite transferencia de objetos. Es decir, puede entregarle un extremo de canal a una pestaña, algo que el bus de difusión no permite. Esa habilidad convierte al Service Worker en el único intermediario capaz de poner en contacto directo a dos pestañas que no se conocen, sin obligarlas a compartir un bus por el que circule todo el tráfico.
Hay también dos limitaciones de arranque que descartan cualquier plan de convertirlo en la puerta de entrada de los datos. La primera es que no controla la primera carga: una página cargada antes de que el trabajador se active no está bajo su control hasta la siguiente navegación, salvo que lo reclame explícitamente, y reclamar clientes a mitad de sesión tiene sus propios efectos. La segunda es que su actualización sigue un ciclo propio, con una versión nueva que queda en espera mientras haya clientes usando la anterior, de modo que el momento en que tu código nuevo empieza a mandar no lo decides tú del todo.
El ciclo de vida que lo descalifica como sede de la base
Aquí está el corazón de la lección. Un Service Worker no es un proceso que arrancas y que permanece: es un manejador de eventos que el navegador instancia cuando hace falta y destruye cuando deja de hacer falta. Fuera de un evento en curso no tiene derecho a existir, y el navegador ejerce ese derecho con bastante agresividad, más aún en móviles y con la batería baja. El único modo de prolongar su vida es mantener una promesa pendiente dentro de un evento, y ni siquiera eso es ilimitado.
// Esto NO sobrevive entre eventos: el ambito global puede reconstruirse
let cacheEnMemoria = new Map();
let conexionAbierta = null;
self.addEventListener('fetch', (e) => {
// Solo mientras la promesa de respondWith siga pendiente hay vida garantizada
e.respondWith(responder(e.request));
});
self.addEventListener('message', (e) => {
// waitUntil extiende la vida durante ese evento, no mas alla
e.waitUntil(procesar(e.data));
});
La consecuencia para una base de datos es demoledora, y no por un motivo sino por una acumulación. La caché de páginas del motor, las sentencias preparadas, los índices calentados y las conexiones abiertas son estado en memoria, y ese estado se evapora sin previo aviso, de modo que el coste de arranque se paga una y otra vez en lugar de una sola. Peor todavía: la terminación puede ocurrir con trabajo a medias, y un motor embebido interrumpido entre dos escrituras deja exactamente los estados intermedios que su diario está diseñado para reparar, con lo que cada suspensión se convierte en una recuperación al siguiente arranque. Y si tu capa usa el manejador exclusivo de OPFS, el resultado es el peor de todos los mundos: un bloqueo sostenido por un contexto que el navegador puede aniquilar cuando le convenga, liberado en un instante que no controlas, mientras las pestañas vivas esperan.
El argumento de que el Service Worker está en todas partes es cierto y no es pertinente. Que un contexto exista en todos los navegadores no dice nada sobre cuánto dura ni sobre qué garantías ofrece mientras dura, y una base de datos no necesita ubicuidad: necesita permanencia y control de su propio cierre. Alojarla ahí produce además la peor clase de fallo posible, porque no falla en desarrollo, donde las herramientas mantienen el trabajador vivo y las pestañas están siempre en primer plano; falla en el móvil de un usuario con la batería al quince por ciento y la aplicación en segundo plano, que es justo donde no puedes depurar nada.
flowchart LR I[Instalar] --> A[Activar] A --> V[Vivo mientras hay un evento] V -->|sin eventos| T[Terminado por el navegador] T -->|llega un evento| V V --> M[Memoria util] T --> P[Memoria perdida] style V fill:#a6e3a1,color:#11111b style T fill:#f38ba8,color:#11111b style P fill:#f9e2af,color:#11111b
Lo que sí conviene pedirle
Descartada la idea de alojar ahí la capa de datos, queda una lista de tareas donde el Service Worker es insustituible, y todas comparten una propiedad: son eventuales, sin estado en memoria y reanudables. Es decir, encajan con su ciclo de vida en lugar de luchar contra él.
Interceptar la red
Servir el armazón de la aplicación sin conexión, aplicar estrategias de caché por tipo de recurso y fabricar respuestas sintéticas leyendo de IndexedDB para peticiones que nunca salen a internet.
Trabajo diferido
Reintentar envíos pendientes cuando vuelve la conectividad, mediante los mecanismos de sincronización en segundo plano donde existan. La cola vive en el disco; el trabajador solo la procesa.
Avisos y despertares
Recibir notificaciones enviadas por el servidor y actuar sobre ellas sin ninguna pestaña abierta es algo que ningún otro contexto de tu aplicación puede hacer.
Emparejar pestañas
Entregar un extremo de canal a un cliente concreto convierte al trabajador en una centralita capaz de conectar directamente a dos pestañas que no se conocían.
Esa última capacidad merece verse en código, porque es el puente hacia la lección siguiente y resuelve un problema real del patrón de líder: cómo establece un seguidor un canal privado con el líder cuando ni siquiera sabe cuál es.
// La pestana seguidora crea el canal y cede un extremo
const { port1, port2 } = new MessageChannel();
port1.onmessage = (e) => resolverRespuesta(e.data);
port1.start();
navigator.serviceWorker.controller.postMessage(
{ tipo: 'conectame-con-el-lider' },
[port2], // el puerto viaja, no se copia
);
// El Service Worker actua de centralita: no guarda estado, solo enruta
self.addEventListener('message', (e) => {
if (e.data.tipo !== 'conectame-con-el-lider') return;
e.waitUntil((async () => {
const idLider = await leerLiderDeIndexedDB(); // el estado vive en disco
const todos = await self.clients.matchAll({ type: 'window' });
const lider = todos.find((c) => c.id === idLider);
if (lider) lider.postMessage({ tipo: 'nuevo-seguidor' }, [e.ports[0]]);
})());
});
Observa el detalle decisivo del ejemplo: el identificador del líder se lee de IndexedDB en cada evento en lugar de guardarse en una variable. Esa es la regla general de cualquier código que escribas para este contexto, y aplicada con rigor lo vuelve fiable: si el trabajador puede morir entre dos eventos, entonces cualquier cosa que necesite recordar tiene que estar persistida antes de que el evento termine, y cualquier operación tiene que poder reanudarse desde cero sin repetir efectos. Escrito así, que lo maten deja de ser un problema y pasa a ser un caso contemplado.
Dónde encaja dentro del patrón de líder
Situado en su papel, el Service Worker aporta tres cosas concretas a la arquitectura que se construye en la lección siguiente, y ninguna de ellas es alojar datos. Aporta enrutado, porque puede entregar puertos y poner en contacto a clientes que no se conocen. Aporta censo, porque la consulta de clientes le da una lista de ventanas abiertas mucho más fiable que contar mensajes en un bus, aunque siga sin decirle cuál de ellas está sana. Y aporta arranque, porque es capaz de servir el armazón de la aplicación sin red mientras la pestaña líder abre la base y prepara el terreno.
Merece la pena señalar también una tarea que parece suya y no lo es: servir los datos del usuario interceptando las peticiones de tu propia API. La idea seduce porque deja la aplicación sin tocar, pero convierte cada lectura local en una petición de red simulada, con su serialización a formato de transporte, su análisis en el cliente y su copia de ida y vuelta, para leer algo que estaba a un índice de distancia. Como estrategia de transición desde una aplicación existente es defendible; como arquitectura de destino en un sistema local-first es pagar el precio de la red para no tener red.
La forma resultante es una división de responsabilidades limpia que conviene memorizar: la base vive en un worker dedicado de la pestaña líder; el arbitraje lo hace la API de bloqueos; el aviso general lo hace el canal de difusión; el enrutado punto a punto lo hace el Service Worker; y la red y la caché de recursos también. Cada pieza en el papel para el que fue diseñada.
Merece la pena extraer de aquí una idea que trasciende con mucho a esta API, porque reaparecerá en cada plataforma moderna que toques. Venimos de una tradición donde ejecutar código significaba poseer un proceso: el proceso arranca cuando tú lo dices, dura mientras tú quieras, guarda lo que le pongas en memoria y termina cuando tú decides. Sobre esa posesión se construyó la práctica totalidad del software de servidor que conocemos, y con ella un conjunto de hábitos tan interiorizados que ya no se ven, como cachear en una variable global, mantener una conexión abierta o dar por hecho que la línea siguiente se ejecutará. Las plataformas modernas han retirado ese contrato sin hacer mucho ruido, y no por capricho, sino porque la energía es finita y un teléfono no puede permitirse mantener vivos los procesos de cuarenta sitios web por si acaso. Lo que te ofrecen ahora es un contexto prestado: el sistema decide cuándo existes, te concede la ejecución mientras atiendes un evento y te retira sin negociación cuando dejas de ser útil. Ese cambio no es una limitación que haya que sortear con trucos para mantenerse vivo, es una disciplina de diseño que se llama exactamente igual en un Service Worker, en una función efímera, en una tarea de fondo de un móvil o en un actor que puede ser desalojado: persiste antes de que termine el evento, haz las operaciones idempotentes, deja el trabajo reanudable y no guardes en memoria nada cuya pérdida no sea recuperable. Lo interesante es que esa disciplina es idéntica a la que el software local-first exige por otras razones, porque un dispositivo que se apaga a mitad de una sincronización plantea el mismo problema que un contexto que muere a mitad de un evento. Aprenderla aquí, donde la muerte llega en treinta segundos y se reproduce a voluntad, es la forma más barata de adquirirla.
- Guarda un valor en una variable global del trabajador, espera a que el navegador lo termine y comprueba desde una pestaña que el valor ha desaparecido.
- Repite el experimento persistiendo ese valor en IndexedDB y verifica que el comportamiento pasa a ser estable.
- Enumera cuántas veces se reconstruye el ámbito global durante media hora de uso normal en un móvil, registrando un contador persistido en cada arranque.
- Implementa la centralita que entrega un puerto a un cliente concreto y comprueba que dos pestañas quedan conectadas directamente, sin pasar por el bus.
- Documenta qué ocurre con tu aplicación cuando hay una versión nueva del trabajador en espera y el usuario mantiene tres pestañas abiertas.
- Revisa tu código actual y marca cada lugar donde asumes que el trabajador seguirá vivo en la siguiente línea. Reescribe esos puntos como operaciones reanudables.