Cómo Implementar RUM para Core Web Vitals en 2026: web-vitals.js, CrUX API y Percentiles p75
Implementa RUM de Core Web Vitals con web-vitals.js v5 y la CrUX API. Recolecta LCP, INP y CLS reales, calcula p75 por dispositivo y compara contra el CrUX oficial en menos de 30 líneas.
Implementar RUM (Real User Monitoring) para Core Web Vitals significa recolectar LCP, INP y CLS desde los navegadores de tus usuarios reales, agregarlos por percentil p75 y segmentarlos por clase de dispositivo, tipo de conexión y ruta. Con la librería web-vitals.js v5, la API navigator.sendBeacon() y la CrUX API se consigue en menos de 30 líneas de código, y es la única fuente de verdad que Google usa para posicionar tu sitio. Los datos sintéticos de Lighthouse mienten cuando el 40 % de tus usuarios están en Android de gama baja con 3G.
Los Core Web Vitals que afectan al SEO son los datos de campo (CrUX), no los de Lighthouse: sin RUM propio vuelas a ciegas.
web-vitals.js v5 expone onLCP, onINP, onCLS, onFCP y onTTFB con reportería incremental y attribution build para identificar el elemento culpable.
navigator.sendBeacon() es obligatorio para no perder métricas al cerrar pestaña: fetch() con keepalive es fallback, nunca el default.
El p75 se calcula por segmento (mobile/desktop, 3G/4G, ruta) y se compara contra los umbrales oficiales: LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1.
La CrUX API pública devuelve datos históricos de 28 días para cualquier origen, útil como benchmark y para vigilar a la competencia.
El error más caro es medir solo desktop en oficina con fibra: te da un p75 verde mientras el CrUX real está en rojo.
¿Qué es RUM y por qué importa más que Lighthouse?
Vamos al grano. RUM (Real User Monitoring) es la práctica de instrumentar la página con JavaScript que mide las métricas de rendimiento reales que experimentan los usuarios y las envía a un backend para agregarlas. La diferencia crítica frente a las pruebas sintéticas (Lighthouse, WebPageTest, PageSpeed Insights en modo "lab") es que RUM refleja el parque real de dispositivos, redes, ubicaciones geográficas y patrones de interacción. En mis últimos cinco proyectos he encontrado, sin excepción, que la brecha entre el LCP de laboratorio y el p75 de campo se movía entre 800 ms y 2.4 segundos. Esa diferencia decide si Google te clasifica en verde o en rojo. Sin matices.
Google, y esto conviene tenerlo claro desde el principio, usa exclusivamente datos de campo para posicionar sitios. El dataset público se llama Chrome UX Report (CrUX) y agrega las sesiones de usuarios de Chrome que aceptaron enviar telemetría. Si tu tráfico es bajo, tu origen no aparece en CrUX y solo verás datos anonimizados a nivel de país. Por eso todo sitio serio necesita RUM propio: te da granularidad por URL, por experimento A/B, por versión de despliegue y en tiempo casi real (segundos, no 28 días).
Las tres métricas que hoy afectan al posicionamiento son LCP (Largest Contentful Paint, umbral 2.5 s), INP (Interaction to Next Paint, umbral 200 ms desde marzo de 2024) y CLS (Cumulative Layout Shift, umbral 0.1). FCP y TTFB no cuentan para SEO, pero son diagnósticos indispensables. Si te interesa profundizar en cómo depurar cada una, tenemos guías dedicadas para optimizar INP con LoAF y scheduler.yield() y para eliminar layout shifts con CSS Containment.
Instalar y configurar web-vitals.js v5
Al lío. web-vitals.js es la librería oficial de Google Chrome, publicada en GoogleChrome/web-vitals. La versión 5.x añade soporte nativo para onINP con reportería incremental (te va dando actualizaciones a medida que la métrica cambia) y una entrega mucho más precisa del navigationType, incluido bfcache-restore. Instálala con tu gestor de paquetes favorito:
npm install web-vitals
La API mínima viable ocupa 15 líneas. Este script debe cargarse tan temprano como sea posible, idealmente inline en el <head> o como un módulo con type="module" sin defer al inicio del <body>. Cada callback se dispara varias veces durante la vida de la página conforme las métricas evolucionan; el último valor emitido antes de visibilitychange es el definitivo:
Ojo con metric.id: es un identificador único por sesión y métrica, no por evento. Si envías el mismo ID varias veces (porque INP mejora o empeora conforme el usuario interactúa), tu backend debe hacer upsert con el valor más reciente. Los valores intermedios son útiles para debugging, pero no deben contarse dos veces en tu p75. Me pasó al principio con una integración a BigQuery: contaba cada delta como métrica independiente y el p75 salía inflado un 30 %. Un dolor.
Enviar métricas al backend con sendBeacon
navigator.sendBeacon() es una API específicamente diseñada para transmitir datos analíticos al cerrar o navegar fuera de una página. A diferencia de fetch(), el navegador garantiza que la petición se envía incluso si el proceso de la pestaña se está destruyendo. La contrapartida: sendBeacon solo acepta POST, no admite cabeceras personalizadas y el body debe ser Blob, ArrayBuffer, FormData, URLSearchParams o string. Nunca funcionará con JSON si el servidor exige Content-Type: application/json; envía como text/plain y parsea en el backend, o usa new Blob([json], { type: 'application/json' }).
Un backend mínimo en Node.js que acepte estos beacons se ve así:
// server.mjs -- endpoint /rum
import { createServer } from 'node:http';
createServer(async (req, res) => {
if (req.url !== '/rum' || req.method !== 'POST') {
res.writeHead(404).end();
return;
}
let raw = '';
for await (const chunk of req) raw += chunk;
try {
const m = JSON.parse(raw);
// Enriquecer con headers antes de persistir
m.ua = req.headers['user-agent'];
m.ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
m.ect = req.headers['ect']; // 4g, 3g, 2g (si CH esta habilitado)
m.dpr = req.headers['dpr'];
m.viewportWidth = req.headers['sec-ch-viewport-width'];
await persist(m); // ClickHouse, BigQuery, Postgres...
} catch (e) {
// Nunca romper el response del beacon
}
res.writeHead(204).end();
}).listen(8080);
Responde siempre 204 sin cuerpo. sendBeacon ignora la respuesta y un body innecesario desperdicia ancho de banda, sobre todo cuando estás recibiendo cientos de eventos por segundo. Para escalar a millones de eventos por día, apunta el endpoint a un Cloudflare Worker o AWS Lambda@Edge que empuje a Kinesis, Kafka o un lake directo. En mi equipo actual el pipeline manda todo a ClickHouse y agregamos p75 cada 60 segundos. Con un solo Worker basta hasta unos 5.000 eventos por segundo, y honestamente es más barato de lo que suena.
Attribution build: identificar el elemento culpable
La primera versión de web-vitals.js solo te decía "tu LCP es 3.2 s". Inservible. La attribution build, disponible con import { onLCP } from 'web-vitals/attribution', adjunta un objeto attribution a cada métrica con el elemento culpable, sus tiempos parciales y una URL de recurso si aplica. Es la diferencia entre saber que hay un problema y saber cómo arreglarlo.
Con estos datos en el backend puedes generar un ranking tipo "los 10 selectores CSS que más CLS causan en /checkout esta semana". Ese informe es oro puro. En un cliente e-commerce eliminamos el 68 % del CLS de la home solo por descubrir que un banner de cookies renderizado tarde estaba desplazando el hero. Lo típico: alguien lo añadió en un sprint hace ocho meses y nadie miró métricas después.
Segmentar por clase de dispositivo y conexión
Un p75 global es una mentira reconfortante. La distribución bimodal es la norma: usuarios de escritorio con fibra viven LCPs de 1.1 s mientras que Android de gama baja con 3G tarda 4.8 s. Si promedias, ninguno de los dos grupos existe. Necesitas segmentar como mínimo por:
Clase de dispositivo: mobile / tablet / desktop. Extráelo del User-Agent o mejor, de Sec-CH-UA-Mobile con Client Hints.
Tipo de conexión: navigator.connection.effectiveType devuelve 4g, 3g, 2g o slow-2g. Envíalo con cada beacon.
Memoria del dispositivo: navigator.deviceMemory devuelve 0.25, 0.5, 1, 2, 4 u 8 GB. Los dispositivos con ≤ 2 GB son otro planeta en INP.
País: derivado de la IP en el edge. La latencia RTT varía 200 ms entre regiones.
Ruta o plantilla: /blog/* tiene métricas distintas que /checkout. No agregues por dominio raíz.
Client Hints simplifican la recolección enormemente. Añade este header en tu servidor origen y el navegador enviará automáticamente las señales relevantes:
En el frontend basta con enriquecer el payload antes de mandarlo:
const conn = navigator.connection || {};
const meta = {
ect: conn.effectiveType, // '4g', '3g'...
rtt: conn.rtt, // ms
downlink: conn.downlink, // Mbps
saveData: conn.saveData, // true en modo ahorro
deviceMemory: navigator.deviceMemory, // GB
hardwareConcurrency: navigator.hardwareConcurrency,
dpr: window.devicePixelRatio,
};
Estos ocho campos te permiten cruzar datos como "p75 INP en móviles con 2 GB de RAM y 3G en la ruta /producto/*", que es el nivel de detalle donde de verdad se descubren los cuellos de botella. Complementa esta instrumentación con nuestra guía de reducción de TTFB con Early Hints y Edge SSR para atacar la parte servidor.
Combinar RUM propio con la CrUX API
La CrUX API pública devuelve datos históricos de 28 días para cualquier origen o URL con tráfico suficiente. Es gratuita hasta 150 QPS con clave de API. La usamos para tres cosas: (1) validar que nuestro RUM propio coincide con lo que Google ve, (2) monitorizar competidores y (3) rastrear regresiones cuando el histograma cambia sin razón aparente. Ejemplo de llamada:
La API History devuelve series semanales de las últimas 25 semanas; ideal para gráficos de tendencia. Si necesitas granularidad diaria y capacidad analítica, el dataset completo vive en BigQuery como chrome-ux-report.materialized.metrics_summary — 40 columnas, particionado por mes, actualizado cada primer martes del mes siguiente. Cuesta menos de 5 USD por consulta para un dominio típico.
Calcular el p75 y configurar alertas
El percentil 75 es, básicamente, el valor por debajo del cual queda el 75 % de las observaciones. Es el umbral oficial de Google: si tu p75 de LCP es 2.4 s, el 75 % de tus usuarios lo experimentan igual o mejor, y estás en verde. Se calcula sobre una ventana de 28 días en CrUX. En tu RUM propio yo recomiendo agregaciones de 24 horas y 7 días, con alertas sobre la ventana corta.
En SQL (ClickHouse, BigQuery, Postgres 14+ con percentile_cont) es una línea:
-- ClickHouse
SELECT
route,
form_factor,
quantileExact(0.75)(value) AS p75,
count() AS samples
FROM web_vitals
WHERE metric = 'LCP'
AND ts > now() - INTERVAL 1 DAY
GROUP BY route, form_factor
HAVING samples >= 100
ORDER BY p75 DESC;
El HAVING samples >= 100 es innegociable: bajo esa muestra el p75 es puro ruido. Para alertas, yo comparo el p75 de las últimas 6 horas contra la media móvil de 7 días; si supera el histórico en 200 ms durante dos ventanas consecutivas, dispara una notificación. Los falsos positivos por deploys se manejan silenciando la alerta 15 minutos tras cada release (no es elegante, pero funciona).
Para conocer el estado del arte en umbrales oficiales, la referencia canónica es la documentación de web.dev sobre Core Web Vitals, que Google actualiza cada vez que cambia una métrica (marzo 2024 fue INP; el próximo candidato es responsiveness por interacción larga).
Errores comunes que arruinan tu RUM
He auditado bastantes implementaciones de RUM y estos son los errores que se repiten y que acaban dando datos falsamente optimistas:
Solo medir la carga inicial: la mayoría de INP ocurre después de segundos de interacción. Si tu librería dispara callbacks solo en load, no ves nada.
Filtrar bots pero incluir prerender: Chrome prerendering (Speculation Rules) dispara métricas artificialmente buenas. Filtra navigationType === 'prerender' o pondéralo aparte. Nuestra guía de Speculation Rules API explica el impacto en detalle.
No enviar el reporte final: si no escuchas visibilitychange, pierdes el CLS acumulado hasta el momento del cierre. web-vitals.js lo hace automáticamente pero si mockeas el módulo en tests, verifica.
Muestrear 1 %: es común para reducir costes, pero destruye la utilidad de los segmentos. Con 1 % en /checkout puede que no llegues a 100 muestras al día. Muestrea al 100 % y agrega en el edge.
Confundir p75 de LCP con LCP p75 por sesión: son distintos. El primero es el percentil 75 de todas las LCPs individuales; el segundo, más útil para diagnóstico interno, ponderado por número de páginas vistas. Google usa el primero para CrUX.
Ignorar bfcache-restore: cuando el usuario navega hacia atrás, la página se restaura desde el bfcache y las métricas son ~0 ms. Excluye navigationType === 'back-forward-cache' del p75 o inflarás artificialmente tu buen desempeño.
Si además del rendimiento de RUM te interesa dominar la parte de imágenes que suele dominar el LCP, complementa esta guía con nuestro artículo sobre optimización de imágenes para LCP con AVIF, WebP y fetchpriority. LCP e imágenes son inseparables: el 82 % de los LCPs son imágenes según CrUX 2026-Q2.
Preguntas frecuentes
¿Cuál es la diferencia entre RUM y datos sintéticos de Lighthouse?
RUM mide las métricas que experimentan tus usuarios reales en sus dispositivos, redes y contextos; los datos sintéticos de Lighthouse simulan una única configuración de laboratorio (Moto G4 emulado, 3G Fast) que rara vez coincide con tu parque real. Solo el RUM alimenta el CrUX que Google usa para el posicionamiento.
¿Es gratis usar la CrUX API?
Sí, hasta 150 consultas por segundo con una clave de Google Cloud gratuita. Para volúmenes mayores o análisis históricos completos se usa el dataset público en BigQuery, que cobra únicamente por bytes escaneados (típicamente menos de 5 USD por consulta para un dominio).
¿Cuánto pesa web-vitals.js en producción?
La build estándar de v5 pesa aproximadamente 3.5 KB gzipped; la attribution build, unos 5.5 KB. Con tree-shaking real (Vite, Rollup) importando solo las métricas necesarias baja a 2.1 KB. Suele ser el ROI más alto por byte de todo tu bundle.
¿Por qué mi RUM muestra INP mejor que el CrUX de Google?
Suele ser por muestreo sesgado: tu RUM captura sesiones con más ancho de banda porque los usuarios lentos abandonan antes de que se cargue tu script. Solución: carga web-vitals.js lo más pronto posible (inline en el head) y no filtres sesiones cortas del p75.
¿Debo enviar cada métrica a Google Analytics 4?
Puedes, pero GA4 impone límites de eventos y cardinalidad que dificultan segmentación granular. Recomiendo un pipeline propio (Cloudflare Worker → ClickHouse o BigQuery) y un dashboard en Grafana o Looker. GA4 es útil como plan B para equipos sin infraestructura analítica.
¿Cuántas muestras necesito para un p75 fiable?
Un mínimo de 100 muestras por segmento y ventana temporal para un intervalo de confianza aceptable. Por debajo, el p75 baila 15–30 % entre horas. Si un segmento no alcanza 100 muestras en 24 horas, agrega la ventana a 7 días o combínalo con segmentos similares.
Guía práctica para optimizar fuentes web en 2026: self-hosting WOFF2, font-display swap, size-adjust contra CLS y variable fonts. Con código real, métricas y checklist de auditoría.
Cómo bajar el TTFB a menos de 200 ms en 2026 combinando Early Hints (HTTP 103), edge SSR con streaming en React 19, caché CDN stale-while-revalidate y Server-Timing para atribuir tiempos.
CLS sigue siendo el Core Web Vital más subestimado en 2026. Esta guía cubre las técnicas modernas: aspect-ratio para medios, size-adjust para fuentes web, content-visibility para listas, CSS Containment y debugging con PerformanceObserver.