Real User Monitoring (RUM) para Core Web Vitals em 2026: Guia Completo com web-vitals.js, CrUX e Análise por Percentil

Como instrumentar Real User Monitoring para Core Web Vitals em 2026: web-vitals.js v4, sendBeacon, análise por percentil p75, CrUX vs RUM próprio, integração com GA4/BigQuery e tracking de SPAs.

Guia RUM Core Web Vitals (2026)

Atualizado: 29 de junho de 2026

Real User Monitoring (RUM) para Core Web Vitals é a prática de coletar métricas de performance diretamente do navegador de visitantes reais (usando a biblioteca web-vitals.js e o PerformanceObserver) e enviá-las para um endpoint de análise via navigator.sendBeacon. Diferente do Lighthouse, que mede um único carregamento sintético em uma máquina com rede simulada, o RUM captura LCP, INP e CLS no percentil 75 (p75) do tráfego real, segmentado por classe de dispositivo, tipo de rede e geografia. É a única forma de saber o que o Google realmente vê quando avalia seu site.

  • O Google ranqueia Core Web Vitals usando dados de campo do CrUX (p75), não a pontuação do Lighthouse. RUM é o único sinal alinhado com o que o algoritmo enxerga.
  • A biblioteca web-vitals.js v4+ expõe LCP, INP, CLS, FCP e TTFB com IDs estáveis para deduplicação e atribuição de elemento culpado.
  • Sempre envie métricas com navigator.sendBeacon dentro de visibilitychange. Listeners em unload são ignorados pelo bfcache moderno.
  • Segmente p75 por device class (mobile vs desktop) e tipo de conexão (effectiveType). Médias agregadas escondem regressões catastróficas em 4G.
  • CrUX tem latência de 28 dias e cobre apenas Chrome desktop/Android. Seu RUM próprio captura Safari, iOS e mudanças em tempo real.
  • Soft navigations em SPAs exigem a flag reportSoftNavs ou tracking manual via History API. Sem isso, suas métricas só refletem o primeiro carregamento.

O que é Real User Monitoring (RUM)?

Real User Monitoring (RUM) é a coleta passiva de métricas de performance (LCP, INP, CLS, TTFB, FCP) direto do navegador de usuários reais durante sessões normais de navegação. Em vez de simular um único carregamento em um datacenter, que é o que o Lighthouse faz, o RUM amostra milhões de pageviews através de redes domésticas reais, dispositivos com bateria fraca, abas em background e CPUs estranguladas pelo termal throttling de meio-dia em Recife.

A diferença prática é brutal. Honestamente, já vi sites com Lighthouse 95+ em laboratório onde o p75 de LCP no campo era 4,8s, três vezes acima do limite "bom" do Google. A causa? Cache do CDN frio para 30% do tráfego do interior, fontes que falhavam a entrega em conexões 3G instáveis, e um teste A/B que injetava 240KB de JS bloqueante apenas para usuários logados (que o Lighthouse, rodando deslogado, nunca via).

RUM resolve isso porque mede o que está acontecendo agora, no navegador do seu usuário, sem opinião. Os dados de campo do Chrome User Experience Report (CrUX) são exatamente isso, agregados pelo Google e usados como sinal de ranqueamento. Seu RUM próprio é o mesmo tipo de dado, mas em tempo real, segmentado do seu jeito, e cobrindo Safari/Firefox/iOS que o CrUX ignora.

RUM vs monitoramento sintético: por que ambos importam

Monitoramento sintético (Lighthouse, WebPageTest, SpeedCurve sintético) executa testes controlados em ambientes reproduzíveis. RUM coleta o caos da realidade. As duas abordagens respondem perguntas diferentes, e quem só usa uma fica cego.

DimensãoRUM (campo)Sintético (laboratório)
Fonte dos dadosNavegadores reais de usuáriosMáquina/container controlado
VariabilidadeAlta. Captura todos os dispositivos, redes, geografiasBaixa. Uma config fixa
INPMede interações reais (cliques, tecladas)Não mede, não há usuário
Latência de feedbackTempo real (ou minutos)Tempo real, mas só de 1 amostra
Boa paraDetectar regressões em produção, alinhar com CrUXReproduzir bugs, comparar branches em CI
Cega paraCausas raiz específicas (precisa de tracing extra)Cauda longa, dispositivos pobres, conexões ruins
CustoBaixo (cliente paga o cálculo)Variável (CI minutos, serviços externos)

A regra que sigo: RUM é o termômetro de saúde do produto (alinhado com o que afeta SEO e UX); sintético é o microscópio para debugar uma regressão específica reproduzível. Se sua única fonte é o Lighthouse rodando uma vez no CI antes do deploy, você está medindo um cenário que talvez nenhum usuário real experimente.

Instalando web-vitals.js e capturando LCP, INP e CLS

A biblioteca oficial do Google, web-vitals.js, é o padrão de facto para coletar Core Web Vitals em RUM. Na v4+, ela expõe APIs estáveis para LCP, INP, CLS, FCP e TTFB, com IDs únicos por métrica para deduplicação e atribuição (qual elemento causou o LCP, qual interação causou o pior INP).

Instalação via npm para sites com bundler:

npm install web-vitals

Para sites sem bundler, importe direto do CDN como ESM:

<script type="module">
  import { onLCP, onINP, onCLS, onTTFB, onFCP }
    from 'https://unpkg.com/web-vitals@4?module';

  // Use a versão "Attribution" para saber QUAL elemento causou o LCP
  // e QUAL evento gerou o pior INP. Indispensável para debug em produção.
</script>

O código mínimo para capturar todas as métricas e logá-las no console:

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

function reportMetric(metric) {
  // metric.id é estável: várias chamadas para a mesma métrica
  // (CLS muda ao longo da sessão) compartilham o mesmo ID, o que facilita upsert
  console.log(metric.name, metric.value, metric.rating, metric);

  // metric.attribution traz o elemento culpado:
  // LCP -> element, url, loadTime, renderTime
  // INP -> eventEntry, eventTarget, eventType, loadState
  // CLS -> largestShiftTarget, largestShiftTime, largestShiftValue
}

onLCP(reportMetric);
onINP(reportMetric);   // Substituiu FID no Core Web Vitals em março/2024
onCLS(reportMetric);
onTTFB(reportMetric);
onFCP(reportMetric);

Note que onCLS e onINP podem disparar várias vezes durante a vida da página. O CLS acumula, e novos INPs ruins substituem o anterior se forem piores. Isso é por design: o p75 que o Google calcula usa o pior valor da sessão, não a média.

Enviando métricas com sendBeacon sem perder dados

O erro número um em implementações de RUM caseiras: enviar a métrica imediatamente via fetch e perder 30% dos dados porque o usuário fechou a aba antes da requisição completar. A solução correta envolve duas decisões: quando enviar e como enviar.

O quando deve ser o evento visibilitychange com estado hidden, não unload nem beforeunload. Modernamente, navegadores ignoram listeners de unload para preservar o bfcache (back-forward cache), e perder o bfcache também derruba seus INPs, porque a página é recarregada.

O como deve ser navigator.sendBeacon, que enfileira a requisição no nível do SO e a entrega mesmo depois da aba fechar:

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

const ENDPOINT = '/api/rum';
const queue = new Set();

function enqueue(metric) {
  queue.add({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,        // 'good' | 'needs-improvement' | 'poor'
    delta: metric.delta,
    id: metric.id,
    navType: metric.navigationType, // 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender'
    // Atribuição reduzida: não enviamos o elemento DOM inteiro
    target: metric.attribution?.element || metric.attribution?.eventTarget || null,
    // Contexto do dispositivo para segmentação posterior
    effectiveType: navigator.connection?.effectiveType,  // '4g' | '3g' | '2g' | 'slow-2g'
    deviceMemory: navigator.deviceMemory,
    hardwareConcurrency: navigator.hardwareConcurrency,
    url: location.pathname,
    ts: Date.now(),
  });
}

function flush() {
  if (queue.size === 0) return;
  const payload = JSON.stringify([...queue]);
  queue.clear();
  // sendBeacon retorna false se a fila do navegador estiver cheia.
  // Nesse caso, caímos para fetch keepalive como fallback.
  const sent = navigator.sendBeacon(ENDPOINT, payload);
  if (!sent) {
    fetch(ENDPOINT, { method: 'POST', body: payload, keepalive: true });
  }
}

// Capturamos TODAS as métricas
[onLCP, onINP, onCLS, onTTFB, onFCP].forEach(fn => fn(enqueue));

// Flush em visibilitychange: funciona para fechar aba, trocar de aba e bfcache
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

// pagehide cobre Safari, que às vezes pula visibilitychange
addEventListener('pagehide', flush);

Análise por percentil e segmentação por classe de dispositivo

Médias mentem. Se 90% dos seus usuários têm LCP de 1,2s e 10% têm 8s, a média é 1,9s, parece OK. Mas o p75 é 1,8s, o p95 é 7,4s, e o Google te julga pelo p75. Pior: aqueles 10% provavelmente são o seu tráfego mobile rural, exatamente o segmento que abandona o carrinho.

Toda análise de RUM deve ser feita em percentis, nunca em média:

  • p50 (mediana): experiência do usuário típico, útil para sanity check
  • p75: a métrica oficial que o Google usa para ranqueamento (CrUX usa p75 de 28 dias)
  • p95: a cauda longa, onde estão os dispositivos antigos e as redes ruins
  • p99: casos patológicos, útil para SLO mas não para meta diária

Segmentação obrigatória que faço em todo dashboard de RUM:

  1. Por device class: navigator.userAgentData?.mobile ou regex no UA. Mobile e desktop têm orçamentos de performance completamente diferentes; agregar os dois esconde regressões mobile-only.
  2. Por effectiveType: navigator.connection.effectiveType ('4g', '3g', etc). Um LCP p75 ruim em "4g" é um problema seu; um LCP p75 ruim em "slow-2g" é parcialmente físico.
  3. Por rota/template: a home tem orçamento diferente da página de produto, que tem orçamento diferente do checkout. Nunca agregue o site inteiro.
  4. Por navigationType: separe back-forward-cache dos demais. O bfcache restaura instantaneamente e jogará seu p75 artificialmente para baixo se misturado.

Se você está construindo isso do zero, a fórmula SQL para p75 em PostgreSQL/BigQuery é percentile_cont(0.75) within group (order by value). Em sistemas que não suportam percentis exatos em grande volume (Prometheus, ClickHouse com histogramas), use HDR histograms ou T-Digest para aproximações com erro < 1%.

CrUX vs seu próprio RUM: quando confiar em cada um

O CrUX é o RUM agregado do Google, coletado de usuários do Chrome que optaram por enviar métricas de uso. Ele é o que o Google usa para ranqueamento, então alinhamento com CrUX é o objetivo final. Mas o CrUX tem três limitações brutais que tornam o RUM próprio insubstituível.

Primeiro, latência: o CrUX usa uma janela rolante de 28 dias. Se você fez um deploy que quebrou o LCP ontem, o CrUX só mostrará a deterioração diluída por 27 dias de dados antigos. O Page Speed Insights mostra dados CrUX diários, mas ainda assim com 2-3 dias de atraso.

Segundo, cobertura: CrUX só inclui Chrome desktop e Chrome Android, e apenas usuários que aceitaram telemetria. Se 40% do seu tráfego é Safari iOS (típico em e-commerce premium no Brasil), você está cego para a maior parte da sua base. No meu último projeto, o p75 do iOS Safari era 2× pior que o do Chrome, e o CrUX não revelava nada disso.

Terceiro, granularidade: CrUX agrega por origem ou por URL (no PSI), sem segmentação por rota, AB test, ou cohort. Seu RUM próprio pode separar landing-page-A vs landing-page-B, ou usuários logados vs deslogados, ou checkout-v1 vs checkout-v2 durante um rollout gradual.

Para nós, especialistas em otimização de LCP e redução de INP, a regra é: use seu RUM para detectar e debugar em horas; use o CrUX para confirmar o impacto SEO em semanas. Quando os dois divergem persistentemente, suspeite de viés de amostra (seu RUM tem JS bloqueado por adblock em 25% das sessões? Safari subrepresentado?).

Enviando Core Web Vitals para GA4 e BigQuery

Se você já paga pelo Google Analytics 4, ele aceita Core Web Vitals como eventos custom, barato e útil para times de produto que já vivem no GA4. Para times de engenharia que querem queries SQL diretas, BigQuery via export do GA4 (ou um endpoint próprio) escala melhor.

Integração GA4 com web-vitals.js:

import { onLCP, onINP, onCLS } from 'web-vitals/attribution';

function sendToGA4(metric) {
  // Arredondamento exigido pelo GA4: CLS x1000 (vira inteiro), demais em ms inteiros
  const value = metric.name === 'CLS'
    ? Math.round(metric.value * 1000)
    : Math.round(metric.value);

  gtag('event', metric.name, {
    value,
    metric_id: metric.id,
    metric_value: metric.value,
    metric_delta: metric.delta,
    metric_rating: metric.rating,
    // Atribuição reduzida: string curta para caber nos 100 chars do GA4
    debug_target: (metric.attribution?.element || metric.attribution?.eventTarget || 'n/a').toString().slice(0, 100),
  });
}

onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);

Para queries no BigQuery (via export GA4), a tabela events_* traz cada métrica como evento. Exemplo: LCP p75 por device class nos últimos 7 dias:

SELECT
  device.category AS device_class,
  APPROX_QUANTILES(
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'metric_value'),
    100
  )[OFFSET(75)] AS p75_lcp_ms
FROM `projeto.analytics_NNN.events_*`
WHERE event_name = 'LCP'
  AND _TABLE_SUFFIX BETWEEN
    FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
    AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
GROUP BY device_class
ORDER BY p75_lcp_ms DESC;

SPAs, soft navigations e atribuição de elemento

Single-page apps (Next.js, Nuxt, SvelteKit, Remix) trocam de "página" via History API sem disparar um novo navigation event. Por padrão, web-vitals.js só mede a primeira navegação, o que significa que se 80% das suas sessões envolvem cliques em links internos, você está medindo só 20% da experiência.

A v4+ da biblioteca suporta soft navigations nativamente, mas requer ativação explícita por enquanto:

import { onLCP, onINP, onCLS } from 'web-vitals/attribution';

const opts = { reportSoftNavs: true };

onLCP(sendToAnalytics, opts);
onINP(sendToAnalytics, opts);
onCLS(sendToAnalytics, opts);

// Cada métrica reportada terá metric.navigationType === 'soft-navigation'
// e metric.entries[0].navigationId para correlacionar com a "página virtual" certa

No Next.js App Router, use o hook useReportWebVitals que internamente já chama a biblioteca correta:

'use client';
import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    // metric tem o mesmo shape do web-vitals.js
    fetch('/api/rum', {
      method: 'POST',
      body: JSON.stringify(metric),
      keepalive: true,
    });
  });
  return null;
}

A atribuição, ou seja, saber qual elemento causou o LCP ou qual interação gerou o pior INP, vem do submodule web-vitals/attribution e é o que transforma RUM de "número ruim" em "consertável". Sem atribuição, você sabe que seu INP está em 280ms; com atribuição, você sabe que foi o onClick do botão "Adicionar ao carrinho" que segurou a thread por 240ms processando um JSON.parse de 800KB. Você não otimiza o que não consegue nomear.

Erros comuns ao implementar RUM

Depois de auditar dezenas de implementações, esses são os tropeços recorrentes:

  • Amostrar antes de agregar. Times com medo do custo amostram 10% dos pageviews no cliente, mas isso enviesa o p75 quando o sampling não é aleatório (ex: sampling baseado em hash do user-id concentra usuários frequentes). Amostre no servidor depois de ingerir, ou use sampling estratificado.
  • Ignorar o bfcache. Páginas restauradas do bfcache têm LCP de 0-50ms. Se você não filtrar navigationType === 'back-forward-cache', seu p75 vai parecer fantástico e esconder regressões reais.
  • Misturar primeira navegação com soft navigations. A primeira é tipicamente 3-5× mais lenta. Agregar os dois faz com que SPAs pareçam melhores do que MPAs, quando na realidade só estão diluindo o pior caso.
  • Não enviar TTFB. Sem TTFB, você não sabe se um LCP ruim é culpa do servidor ou do frontend. Sempre meça. Para o lado do servidor, veja nosso guia de redução de TTFB.
  • Logs verbosos no console em produção. Cada console.log de objeto grande em RUM custa frames; não logue metric.attribution inteiro em produção, só na build de dev.
  • Tracking de PII no payload. URLs com tokens, IDs de usuário no path, cabeçalhos de auth, tudo isso pode vazar via RUM. Faça scrubbing antes do sendBeacon.

A meta de qualquer programa de RUM maduro: que o p75 de LCP, INP e CLS, segmentado por device class e rota, caia no painel "good" do CrUX em 28 dias. Quando isso acontece, seu Search Console começa a refletir, e aí sim o trabalho de campo se converte em tráfego orgânico.

Perguntas frequentes

Qual a diferença entre RUM e monitoramento sintético?

RUM coleta métricas de navegadores de usuários reais durante uso normal, capturando a variabilidade de dispositivos, redes e geografias. Monitoramento sintético executa testes controlados (Lighthouse, WebPageTest) em ambientes reproduzíveis. RUM é melhor para detectar regressões em produção e alinhar com o CrUX; sintético é melhor para debugar cenários específicos em CI.

Qual biblioteca usar para medir Core Web Vitals no cliente?

Use a biblioteca oficial do Google, web-vitals (v4+), instalada via npm ou importada como ESM do CDN. Para Next.js, use o hook useReportWebVitals de next/web-vitals que já encapsula a lib. Para atribuição de elemento culpado (essencial para debug), importe do submodule web-vitals/attribution.

Por que minha pontuação do Lighthouse difere dos dados do CrUX?

Lighthouse executa um único teste sintético em ambiente controlado (CPU 4× throttled, rede 3G simulada, sem cache, sem extensões). CrUX agrega p75 de 28 dias de tráfego Chrome real, com toda a variabilidade de dispositivos, redes e estados de cache. Divergência é normal. O Google usa CrUX para ranqueamento, então otimize para o campo, não para o laboratório.

Qual percentil devo usar como meta de Core Web Vitals?

O percentil 75 (p75), porque é o que o Google usa no CrUX para classificar seu site como "good", "needs improvement" ou "poor". Meta: p75 LCP < 2,5s, p75 INP < 200ms, p75 CLS < 0,1. Sempre segmente por device class. Atingir p75 bom apenas em desktop mas ruim em mobile não passa no Search Console.

Como envio Core Web Vitals para o Google Analytics 4?

Use web-vitals.js com um callback que chama gtag('event', metric.name, ...). Arredonde CLS multiplicando por 1000 (o GA4 exige inteiro) e mantenha os demais em milissegundos inteiros. Inclua metric_id para deduplicação, metric_rating para filtros rápidos, e uma string curta de atribuição em debug_target para identificar elementos culpados.

Nadia El-Sayed
Sobre o Autor Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.