Los fallos reales: congelación, relojes lentos y liderazgo perdido
Una pestaña suspendida sigue sosteniendo su bloqueo sin responder a nadie, los temporizadores de segundo plano hacen mentir a cualquier latido, y de ahí salen los tres fallos que de verdad verás en producción.
Todo lo anterior funciona a la primera en tu máquina, con dos pestañas visibles y un portátil enchufado. Los problemas de este patrón no aparecen en el desarrollo: aparecen a las tres horas, en un móvil con la batería al doce por ciento, en una pestaña que el usuario abrió el martes y no ha vuelto a mirar. Ahí el navegador deja de comportarse como un entorno de ejecución continuo y pasa a ser lo que realmente es —un administrador de recursos que congela, ralentiza y descarta lo que no está delante—, y el modelo mental de que las pestañas están vivas o muertas se rompe. Esta lección cierra el nivel con los tres fallos que verás de verdad y con la única defensa que resiste: no confiar en haber detectado nada.
- Distinguir los cuatro estados reales de una pestaña y saber cuál de ellos libera el bloqueo y cuál no.
- Entender por qué un latido periódico produce relevos falsos en segundo plano y nunca debe gobernar el liderazgo.
- Proteger el sistema con una época creciente para que un líder resucitado no pueda hacer daño.
- Diseñar la recuperación tras un relevo sin perder peticiones ni aplicar escrituras dos veces.
Viva, muda o destruida
Una pestaña no está solo abierta o cerrada. Entre esos dos extremos hay estados intermedios que el navegador gestiona por su cuenta y que cambian por completo lo que puedes esperar de ella. La distinción decisiva para nosotros es una sola: si el contexto se destruye, el bloqueo se libera; si solo se detiene, el bloqueo sigue tomado y su dueño no contesta.
flowchart TB A[Pestana lider en primer plano] --> B[Pasa a segundo plano] B --> C[Temporizadores ralentizados y el bloqueo sigue tomado] C --> D[El navegador la congela] D --> E[No ejecuta nada y no responde a ningun mensaje] E --> F[El navegador la descarta por presion de memoria] F --> G[El contexto muere y el bloqueo se libera] G --> H[Otra pestana hereda el liderazgo] E --> I[El usuario vuelve y la pestana se reanuda] I --> A style E fill:#f38ba8,color:#11111b style G fill:#a6e3a1,color:#11111b style C fill:#f9e2af,color:#11111b
El estado peligroso es el rojo. Una pestaña congelada no ha muerto: su memoria sigue ahí, su trabajador sigue instanciado, su manejador de OPFS sigue abierto y su bloqueo sigue tomado, pero no ejecuta ni una instrucción. Para el resto de tus pestañas es indistinguible de un líder ocupadísimo, y a diferencia de la muerte, la congelación no dispara ninguna liberación. Ese es el fallo que rompe los diseños ingenuos, y es exactamente el escenario en el que un algoritmo distribuido clásico dejaría de poder distinguir un participante caído de uno lento.
La plataforma te avisa de la transición, y conviene escucharla. El evento de congelación llega justo antes de detenerse y el de reanudación justo al volver; una pestaña restaurada desde la caché de retroceso y avance lo indica en su evento de presentación; y una pestaña que fue descartada y se vuelve a cargar lo declara en una propiedad del documento.
document.addEventListener('freeze', () => {
// Ultimo instante de ejecucion antes de quedar detenida
soltarLoQueNoDebaSobrevivir();
});
document.addEventListener('resume', () => {
// Volvemos a la vida: nada de lo que creiamos sigue siendo necesariamente cierto
revalidarLiderazgo();
});
addEventListener('pageshow', (ev) => {
if (ev.persisted) revalidarLiderazgo(); // veniamos de la cache de navegacion
});
if (document.wasDiscarded) reconstruirEstado();
Merece la pena seguir la cadena completa porque el resultado sorprende. Una pestaña que el usuario dejó abierta el martes ganó el liderazgo, abrió la base y retiene el bloqueo. El navegador la congela por inactividad. El usuario abre hoy una pestaña nueva de tu aplicación: entra en la cola, espera a un líder que jamás contestará y se queda en la pantalla de carga para siempre. No hay error, no hay excepción y no hay nada en la consola. El comportamiento exacto de la caché de navegación respecto a los bloqueos ha variado entre versiones y no es uniforme entre navegadores, así que no lo des por supuesto en ninguna dirección: mídelo tú, en los navegadores que te importan, y diseña para el peor caso. La defensa mínima es que ninguna espera de arranque sea indefinida y que, agotado el plazo, la interfaz diga algo verdadero en lugar de girar.
Latidos que mienten
La reacción instintiva ante el líder mudo es construir un detector: que el líder emita una señal cada pocos segundos y que los seguidores lo declaren muerto si dejan de recibirla. Esa idea es la peor decisión posible en este entorno, y la razón es concreta y medible.
Los navegadores ralentizan los temporizadores de las páginas en segundo plano de forma agresiva. Primero los limitan a una ejecución por segundo, y tras unos minutos de inactividad pasan a una cadencia del orden de un minuto; en móvil, la pestaña puede quedar suspendida por completo. Un líder perfectamente sano y en pleno funcionamiento dejará de emitir su latido a los pocos minutos de pasar a segundo plano, sencillamente porque su reloj ya no corre como tú creías.
El resultado es el peor de los dos mundos. Los seguidores declaran muerto a un líder vivo, alguien intenta tomar el mando y, cuando el usuario vuelve a la pestaña original, esta se reanuda convencida de seguir mandando. Ahora hay dos pestañas que se creen líderes, y como el bloqueo real nunca se liberó, la segunda ni siquiera pudo abrir la base: tienes un líder que no puede escribir y otro que sí, ambos anunciándose. Un latido no ha detectado un fallo; lo ha fabricado.
No inventes latidos
La única señal de vida fiable ya te la da el navegador al conceder el bloqueo a otro. Cualquier detector que escribas encima solo puede añadir falsos positivos, porque no distingue congelación de muerte y el navegador sí.
Los relojes no son tuyos
Ningún plazo medido con temporizadores dentro de una pestaña oculta significa lo que crees. Sirven para la interfaz, no para razonar sobre el estado de otro participante.
El silencio no es muerte
Que nadie conteste puede significar muerto, congelado, ocupado o sin haber recibido el mensaje. Son cuatro casos con respuestas distintas y desde fuera son el mismo.
Cuenta con el resucitado
Toda pestaña congelada volverá algún día, con su estado intacto y sus creencias caducadas. Diseñar para eso es más barato que intentar impedirlo.
Y una trampa relacionada que conviene nombrar: mover el liderazgo a un trabajador de servicio para escapar de la congelación no funciona. Un trabajador de servicio se termina cuando lleva un rato sin trabajo, y esa terminación libera su bloqueo con la misma limpieza con la que lo haría una pestaña cerrada. El liderazgo desaparecería solo, en un momento imprevisible y sin que nadie hubiera cerrado nada.
La época: protegerse del líder resucitado
Si detectar es imposible, la salida es la contraria: hacer que equivocarse no cause daño. Es el mecanismo que la literatura de sistemas distribuidos llama ficha de vallado, y aquí cuesta un número entero. Cada mandato lleva una época estrictamente creciente, todo mensaje que sale del líder la incluye, y cualquiera que reciba una época menor que la última conocida descarta el mensaje sin pensarlo.
let ultimaEpocaVista = 0;
canal.addEventListener('message', ({ data }) => {
if (typeof data.epoca !== 'number') return;
if (data.epoca < ultimaEpocaVista) return; // un fantasma de un mandato anterior
ultimaEpocaVista = data.epoca;
procesar(data);
});
// Y en el lider, al reanudarse tras una congelacion
function revalidarLiderazgo() {
if (miEpoca < ultimaEpocaVista) {
desmontarTrabajoDeLider(); // hubo relevo mientras dormiamos
location.reload(); // reincorporarse desde cero
}
}
La época no puede ser un contador en memoria, porque cada pestaña arrancaría con el suyo y no serían comparables. Necesitas un valor con orden global entre pestañas, y hay dos fuentes razonables: el reloj del sistema en el instante de la concesión, que basta porque todas las pestañas comparten reloj dentro de la misma máquina, o un contador persistido que el nuevo líder incremente al tomar el mando, más robusto ante cambios de hora y más caro porque exige una escritura.
Fíjate en que la exclusividad de OPFS actúa por sí sola como protección en la capa que más importa. Un líder resucitado que intente escribir con su manejador antiguo lo tiene todavía abierto, pero el sucesor no habrá podido abrir el suyo; y si el sucesor sí lo consiguió, es porque el navegador destruyó al anterior y con él su manejador. En ninguna de las dos ramas hay dos escritores reales sobre el fichero. Lo que la época protege no es el disco, es el diálogo: respuestas tardías de un líder anterior que llegan después del relevo y que un seguidor podría creerse, avisos de invalidación de un estado que ya no existe y confirmaciones de escrituras que nunca se aplicaron. Las dos protecciones son complementarias y ninguna sustituye a la otra.
Recuperarse sin perder peticiones
Queda el trabajo del sucesor, y conviene abordarlo con una premisa clara: el nuevo líder hereda el puesto, no el estado. No sabe qué transacción quedó a medias, qué peticiones estaban en vuelo ni qué prometió su antecesor. Su recuperación tiene que apoyarse solo en lo que hay escrito en el almacenamiento y en lo que los seguidores le cuenten al reincorporarse.
async function arrancarComoLider() {
const worker = await abrirBaseEnWorker();
await recuperarTransaccionIncompleta(worker); // el motor deshace lo que quedo a medias
miEpoca = Date.now();
canal.postMessage({ tipo: 'lider', id: miId, epoca: miEpoca });
return () => worker.terminate();
}
// Cada seguidor, al ver un lider nuevo, decide que hacer con lo pendiente
canal.addEventListener('message', ({ data }) => {
if (data.tipo !== 'lider') return;
for (const [id, p] of pendientes) {
if (p.idempotente) reenviar(id, data.epoca);
else { pendientes.delete(id); p.reject(new Error('RELEVO_DE_LIDER')); }
}
});
De los pasos anteriores, el primero es el que la gente olvida y el más importante: dejar que el motor de la base recupere su propio estado antes de aceptar nada. Un fallo del líder a mitad de una escritura es, desde el punto de vista de SQLite y de cualquier motor con registro de escritura anticipada, exactamente lo mismo que un corte de corriente, y es un caso que ese motor lleva décadas resolviendo bien. Tu trabajo no es reinventar la durabilidad: es no interferir con ella y no empezar a atender consultas antes de que haya terminado.
Y para lo que no es idempotente, la respuesta ya la tienes del nivel anterior en esta misma ruta: identificadores de operación y un registro de los que ya se aplicaron, dentro de la misma transacción que el cambio. El sucesor puede entonces contestar con certeza a la pregunta que ningún tiempo de espera puede responder —esta escritura, ¿llegó a ocurrir?—, y esa certeza es la diferencia entre reintentar sin miedo y duplicar el importe de una factura.
Aquí está la conclusión del nivel, y es la idea que más lejos vas a llevarte de estas cinco lecciones porque no trata de pestañas ni de navegadores. Cuando alguien afronta por primera vez un sistema con participantes que fallan, su instinto es siempre el mismo: mejorar la detección. Un latido más frecuente, un plazo mejor calibrado, una heurística que distinga al lento del caído. Ese camino no tiene final, y el resultado célebre que lo explica es de 1985 y se llama FLP: en un sistema asíncrono, donde ningún mensaje tiene cota de tiempo, no existe algoritmo alguno que garantice el acuerdo si un solo participante puede fallar, porque un participante caído y uno arbitrariamente lento son indistinguibles por construcción. Tus pestañas son ese sistema, y de forma extrema: el navegador te congela una a voluntad, sin avisar, durante horas, y luego la devuelve entera con todas sus creencias intactas. Ninguna calibración sobrevive a eso. Lo que sí sobrevive es cambiar la pregunta, y ese es el giro que separa a quien construye sistemas frágiles de quien construye sistemas que aguantan: en lugar de preguntar cómo sé si el otro está vivo, pregunta qué daño causaría equivocarme. Si la respuesta es ninguno, ya no necesitas saberlo. La época hace que un líder resucitado sea inofensivo en lugar de detectarlo; la idempotencia hace que un reintento dudoso sea seguro en lugar de resolver la duda; el registro de deduplicación convierte una pregunta imposible sobre el tiempo en una consulta trivial sobre datos; y la propia exclusividad de OPFS impide físicamente el daño que ningún detector habría evitado a tiempo. Ninguna de esas cuatro defensas averigua nada, y precisamente por eso funcionan siempre. Guarda esta idea con cuidado, porque es la misma que gobierna los quince niveles de CRDT que vienen después: allí tampoco habrá forma de saber qué hizo el otro dispositivo ni cuándo, y la solución no será mejores relojes ni mejores detectores, sino estructuras de datos cuya corrección no dependa de haberlo sabido. Este nivel ha sido tu primer ensayo, con dos pestañas y un solo navegador, de la disciplina que sostiene todo el software local-first.
- Deja una pestaña líder en segundo plano una hora y comprueba con marcas de tiempo cuánto se ralentizaron sus temporizadores.
- Fuerza la congelación de la pestaña líder desde las herramientas del navegador y observa qué le ocurre a una pestaña nueva que intente arrancar.
- Documenta el comportamiento de tu navegador con la caché de retroceso: ¿la pestaña restaurada seguía sosteniendo el bloqueo?
- Implementa la época creciente y demuestra que una respuesta tardía de un líder anterior se descarta sin efectos.
- Añade la revalidación al reanudarse y comprueba que un líder resucitado se desmonta solo en lugar de seguir anunciándose.
- Mata al líder durante una escritura larga y verifica que el sucesor recupera la base antes de atender la primera consulta.
- Pon un plazo máximo al arranque de los seguidores y escribe el mensaje honesto que verá el usuario cuando ese plazo se agote.