wandres.dev
MEDIR EN PRODUCCIÓN · La librería web-vitals y los percentiles

Enviar los datos con sendBeacon y visibilitychange

Por qué el envío de métricas es un problema distinto al de una petición normal, cómo garantizar que los datos salgan cuando la página se cierra, y el patrón de agrupación en una sola petición.

⏱ 17 min

Medir es la mitad fácil. La otra mitad es conseguir que los datos lleguen a tu servidor en un momento en el que la página puede estar cerrándose, el usuario puede estar cambiando de pestaña y el navegador puede estar a punto de descartar todo el contexto. Ese problema tiene una solución estándar, sendBeacon combinado con el evento visibilitychange, y varias soluciones aparentes que fallan en silencio.

🎯 Al terminar esta lección sabrás
  • Explicar por qué una petición normal no sirve para enviar métricas al final de la vida de la página.
  • Implementar el envío con navigator.sendBeacon y sus límites.
  • Elegir el evento correcto del ciclo de vida y justificar por qué no es unload.
  • Agrupar varias métricas en una sola petición sin perder ninguna.

El problema del último momento

Dos de las tres Core Web Vitals, el CLS y el INP, no tienen valor definitivo hasta que la página termina. El CLS puede seguir acumulando ráfagas mientras la página viva, y el INP puede empeorar con la siguiente interacción. Esperar al final es obligatorio.

El final, sin embargo, es un momento hostil. Cuando el usuario cierra la pestaña o navega a otro sitio, el navegador desmonta el documento y cancela las peticiones en vuelo. Una llamada a fetch lanzada en ese instante tiene muchas probabilidades de no completarse. Y en sistemas operativos móviles, los navegadores a menudo ni siquiera ejecutan las devoluciones de llamada de descarga de pestañas en segundo plano: el proceso simplemente se termina.

Esto significa que hay que resolver dos cosas: un mecanismo de envío que sobreviva a la descarga del documento, y un momento de disparo que se ejecute de verdad.

El mecanismo y el momento

sendBeacon

navigator.sendBeacon está diseñado exactamente para este caso. Encola una petición POST que el navegador se compromete a enviar aunque el documento desaparezca, y devuelve inmediatamente un booleano que indica si el encolado tuvo éxito.

import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function enviar(metrica) {
  const cuerpo = JSON.stringify({
    nombre: metrica.name,
    valor: metrica.value,
    clasificacion: metrica.rating,
    id: metrica.id,
    tipoNavegacion: metrica.navigationType,
    ruta: location.pathname,
  });

  navigator.sendBeacon('/analitica', cuerpo);
}

onCLS(enviar);
onINP(enviar);
onLCP(enviar);
onFCP(enviar);
onTTFB(enviar);

Tres características de sendBeacon que hay que conocer:

  • Es siempre POST y no permite personalizar cabeceras. Si envías una cadena, el tipo de contenido resultante es text/plain, lo cual evita una comprobación previa de CORS en peticiones a otro origen. Si necesitas application/json, envuélvelo en un Blob con ese tipo, pero entonces la petición deja de ser simple y puede requerir la comprobación previa.
  • No puedes leer la respuesta. Es un envío sin retorno, por diseño.
  • Tiene un límite de tamaño. No está fijado en la especificación con un valor único, pero los navegadores imponen un tope del orden de decenas de kilobytes para el total de datos encolados. Para métricas de rendimiento sobra, salvo que envíes el array de entradas completo, que puede ser grande. No lo envíes tal cual.

Existe una alternativa moderna: fetch con la opción keepalive: true, que tiene la misma garantía de supervivencia y sí permite cabeceras y leer la respuesta. Comparte el mismo tipo de límite de tamaño. Es una opción válida si necesitas control sobre las cabeceras.

visibilitychange, y no unload

El evento correcto para volcar los datos es visibilitychange cuando el estado pasa a hidden.

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    volcar();
  }
});

La razón de que no sea unload ni beforeunload es contundente y está documentada en la guía del ciclo de vida de la página: en sistemas operativos móviles, esos eventos no se disparan de forma fiable cuando el usuario cambia de aplicación o el sistema descarta la pestaña. Un sitio que use unload para enviar métricas pierde una fracción grande y sesgada de sus datos móviles. Además, registrar un escuchador de unload impide que la página entre en la caché de retroceso y avance, lo cual empeora el rendimiento real de tus usuarios: estarías degradando la experiencia para medirla.

visibilitychange cubre los dos casos que importan, el paso a segundo plano y la descarga, porque el navegador dispara el paso a oculto antes de descargar.

La librería web-vitals ya llama a tu devolución de llamada cuando la visibilidad cambia a oculta para las métricas que lo necesitan. Lo que tú tienes que resolver con este evento es el volcado de la cola, si agrupas.

⚠️
El evento pageshow y la caché de retroceso y avance

Si tu página se restaura desde la caché de retroceso y avance, la librería genera métricas nuevas con un id distinto. Para que tu backend no las mezcle con las de la carga original, incluye el id en cada envío y agrupa por él. Y si mantienes estado propio entre envíos, escucha pageshow y comprueba su propiedad persisted para reiniciarlo cuando la página venga de esa caché.

Agrupar en una sola petición

Enviar cinco peticiones por carga de página multiplica el coste de red y el de proceso en tu servidor. El patrón recomendado es acumular en una cola y volcarla cuando la página pase a segundo plano.

import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

const cola = new Set();

function encolar(metrica) {
  cola.add({
    nombre: metrica.name,
    valor: metrica.value,
    clasificacion: metrica.rating,
    id: metrica.id,
    tipoNavegacion: metrica.navigationType,
  });
}

function volcar() {
  if (cola.size === 0) return;

  const cuerpo = JSON.stringify({
    ruta: location.pathname,
    metricas: [...cola],
  });

  navigator.sendBeacon('/analitica', cuerpo);
  cola.clear();
}

onCLS(encolar);
onINP(encolar);
onLCP(encolar);
onFCP(encolar);
onTTFB(encolar);

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') volcar();
});

Tres detalles del patrón que importan y que se pasan por alto:

cola.clear() después de enviar. Si la página vuelve a primer plano y luego se vuelve a ocultar, se disparará otro volcado; sin vaciar, enviarías los mismos datos dos veces.

El Set no evita duplicados de verdad, porque cada llamada crea un objeto nuevo y los objetos se comparan por referencia. Sirve para no duplicar la misma referencia. La desduplicación real la hace tu backend agrupando por id y quedándose con el último valor de cada uno.

No metas metric.entries en el cuerpo. El array de entradas puede ser grande, especialmente en CLS y en INP, y JSON.stringify sobre él produce cargas de decenas de kilobytes que pueden superar el límite de sendBeacon y hacer que se pierda todo el envío en silencio. Extrae solo lo que necesites.

El endpoint del otro lado

Unas notas sobre el receptor, porque condicionan el diseño del cliente.

Responde con 204 y nada más. No hace falta cuerpo, y sendBeacon no puede leerlo de todos modos.

No hagas trabajo síncrono. El endpoint recibirá una petición por carga de página; escribe a una cola o a un almacén de series temporales, no a una base de datos relacional con índices.

Registra la marca de tiempo del servidor, no la del cliente. Los relojes de los clientes están mal con más frecuencia de lo que parece.

Guarda el agente de usuario en crudo y derívalo en el análisis, no en el momento de ingesta. Cuando dentro de seis meses quieras segmentar por una versión concreta de navegador, agradecerás no haberlo normalizado a tres categorías.

La métrica que más se pierde es la de los usuarios que se van rápido, y es la que más necesitas

Hay una asimetría cruel en este sistema de envío. El CLS y el INP se reportan al pasar a oculto, y el LCP se reporta en cuanto está disponible. Pero si el usuario abandona la página a los tres segundos porque estaba tardando demasiado, el LCP puede no haber ocurrido todavía, el INP puede no haberse producido, y lo único que enviarás es el TTFB. El resultado es que las visitas peores contribuyen menos muestras que las buenas, y en el caso del LCP puede que no contribuyan ninguna. Tu percentil 75 está calculado sobre una población de la que faltan precisamente los peores. La forma de detectarlo, y casi nadie la implementa, es contar también las visitas que no reportan cada métrica: registra un evento en el TTFB, que ocurre siempre, y compara su volumen con el de LCP. Si te llegan cien mil TTFB y ochenta mil LCP, tienes un veinte por ciento de visitas que se fueron sin ver el contenido principal, y ese número es una métrica de producto por derecho propio, mucho más elocuente que cualquier percentil.