La Transición del Estado de Visibilidad: Medir Vistas de Página Sin Beacons Fiables
pagehide, visibilitychange y la Beacon API se comportan de forma distinta en cada navegador. Así se registra el final de una sesión de manera fiable, sin cookies ni identificadores persistentes.
Registrar que una página se ha visto es fácil. Registrar cuándo se marchó realmente el usuario —de forma fiable, entre cambios de pestaña, paso a segundo plano y cierres bruscos— es uno de los problemas más espinosos de la analítica web. Importa porque las métricas de interacción (tiempo de permanencia, rebote, profundidad de scroll) dependen de capturar una señal limpia de fin de sesión. Si te equivocas, o pierdes datos o los inflas.
Monoid resuelve esto sin cookies, localStorage ni ningún identificador persistente. Esa restricción en realidad simplifica las cosas: nunca necesitamos reconciliar una sesión entre cargas de página, así que lo único que importa es emitir una única muestra final precisa por ciclo de vida de la página. Esto es cómo se comportan las primitivas del navegador, dónde están las trampas, y cuál de ellas usa realmente el propio tracker de Monoid.
Por qué unload es el evento equivocado
El enfoque histórico consistía en escuchar unload o beforeunload y lanzar un XHR síncrono. Hoy eso es activamente dañino. El evento unload no es fiable en móvil: cuando el usuario manda una pestaña a segundo plano y el sistema operativo la reclama, unload a menudo no llega a dispararse. Peor aún, registrar un manejador de unload descalifica la página de la caché de avance/retroceso (bfcache) en varios navegadores, perjudicando el rendimiento de navegación.
La guía de la Page Lifecycle API de web.dev es explícita: trata la transición de visibilitychange a hidden como el último evento fiable que vas a observar. No cuentes con que unload o pagehide se disparen en móvil.
El emparejamiento visibilitychange + pagehide
El patrón robusto es escuchar visibilitychange y comprobar document.visibilityState === 'hidden', más pagehide como señal secundaria. Cada vez que la página pasa a oculta, vuelcas lo que necesites enviar. La documentación de MDN sobre la Page Visibility API confirma que visibilitychange se dispara cuando una pestaña pasa a segundo plano o el navegador se minimiza: la aproximación más cercana a "el usuario dejó de mirar".
La complicación: visibilitychange a oculta puede dispararse varias veces en una sesión (cambiar de pestaña, volver, cambiar otra vez). Así que debes hacer tu volcado idempotente, aplicarle debounce, o reiniciar tu propio reloj para que cada disparo reporte un segmento nuevo y sin solapamiento, en vez de un total creciente. Monoid sigue este último enfoque: el temporizador detrás de su muestra de tiempo de permanencia se reinicia en cada transición de visibilitychange, así que un segundo evento hidden más adelante en el mismo ciclo de vida de la página solo reporta el tiempo desde el último disparo. Monoid escucha visibilitychange (junto a un respaldo de beforeunload ya existente) pero no añade un listener separado de pagehide — ambos se solapan lo suficiente en los casos que importan como para que un segundo listener solo reintrodujera el problema de doble conteo sin cubrir terreno significativamente mayor.
Enviar datos desde una página moribunda: navigator.sendBeacon
No puedes lanzar un fetch asíncrono durante visibilitychange y esperar que se complete: el navegador puede desmontar la página antes. Para esto existe la especificación de la Beacon API. navigator.sendBeacon(url, data) encola un POST pequeño que el navegador garantiza intentar incluso después de que el documento haya desaparecido, sin bloquear el hilo principal ni retrasar la navegación.
Beacon tiene límites que conviene respetar:
- Tamaño de la carga. La especificación permite al agente de usuario rechazar beacons por encima de un límite definido por la implementación (habitualmente 64 KB). Mantén las cargas diminutas: las de Monoid son de unos pocos cientos de bytes.
- Método. Los beacons siempre son POST. Si tu endpoint en el edge espera GET, adáptalo.
- Sin respuesta. No puedes leer nada de vuelta. Dispara y olvida.
Una alternativa moderna es fetch(url, { keepalive: true }), que el estándar Fetch define como permitir que una petición sobreviva al documento. keepalive te da cabeceras y métodos de los que Beacon carece, pero comparte el mismo techo de tamaño. La sabiduría convencional dice usar Beacon primero y recurrir a fetch con keepalive — Monoid en realidad invierte eso, por razones que explica la siguiente sección.
Una implementación mínima y sin cookies
Aquí tienes un patrón de referencia que combina todo lo anterior — sendBeacon primero, fetch keepalive como su respaldo, activado tanto por visibilitychange como por pagehide, con una protección para que un disparo repetido no envíe dos veces:
let sent = false;
function flush() {
if (sent) return;
sent = true;
const payload = JSON.stringify({
path: location.pathname,
// sin identificadores, sin cookies, sin fingerprint
visibleMs: Math.round(performance.now())
});
const ok = navigator.sendBeacon('/collect', payload);
if (!ok) {
fetch('/collect', { method: 'POST', body: payload, keepalive: true });
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);
Fíjate en lo que falta: ningún listener de unload, así que se preserva la elegibilidad para bfcache. Ningún ID almacenado entre cargas. La cifra de visibleMs usa performance.now(), un reloj monotónico que no necesita marca de tiempo de reloj de pared ni estado persistente.
El propio tracker de Monoid sigue un camino relacionado pero distinto: solo fetch keepalive (sin sendBeacon — no puede expresar el mode: 'cors' que necesita una instalación entre orígenes distintos), sin protección sent, y un temporizador que se reinicia en cada transición de visibilidad en lugar de dispararse una vez y quedar en silencio (el filtrado de consentimiento y Do Not Track se omite abajo por claridad):
function dur() {
fetch('/duration', {
method: 'POST',
body: JSON.stringify({ site_id: siteId, duration_ms: Date.now() - t }),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
mode: 'cors',
});
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') dur();
t = Date.now();
});
window.addEventListener('beforeunload', dur);
Reiniciar t en cada transición hace el mismo trabajo que la protección sent de arriba, sin necesitar una bandera: cada llamada solo reporta el segmento desde el último reinicio del reloj, así que un disparo de visibilitychange seguido de un beforeunload en el mismo cierre de pestaña reporta un segmento real y otro casi-cero inofensivo, en lugar de contar dos veces.
Por qué esto encaja con la analítica privacy-first
Como Monoid nunca cose sesiones entre sí, cada petición es una observación completa y desechable. No hay identificador entre páginas que proteger, así que una muestra perdida o duplicada te cuesta un punto de datos, no un perfil de usuario corrompido. El patrón visibilitychange más beforeunload que Monoid realmente distribuye da una medición precisa de la permanencia manteniendo la página rápida y compatible con bfcache, lo que a su vez mejora las Core Web Vitals que quizá estés intentando medir.
Mide la marcha, no a la persona. El navegador ya te da todo lo que necesitas.
Comments
Loading comments…