Web Vitals: medindo performance real do usuário

1. O que são Web Vitals e por que monitorá-las

Web Vitals são um conjunto de métricas definidas pelo Google para quantificar a experiência real do usuário em aplicações web. As Core Web Vitals incluem três métricas principais:

  • LCP (Largest Contentful Paint): mede o tempo até o maior elemento visível ser renderizado — ideal abaixo de 2,5 segundos
  • FID (First Input Delay) / INP (Interaction to Next Paint): mede a responsividade a interações do usuário — ideal abaixo de 100ms
  • CLS (Cumulative Layout Shift): mede estabilidade visual — ideal abaixo de 0,1

Métricas complementares como FCP (First Contentful Paint) e TTFB (Time to First Byte) ajudam a diagnosticar gargalos específicos.

O Google usa essas métricas como fator de ranqueamento no Page Experience Update. Diferente de métricas de laboratório (Lighthouse), Web Vitals são métricas de campo (RUM — Real User Monitoring), coletadas de usuários reais em produção.

2. Coletando Web Vitals no navegador com JavaScript puro

A Performance API expõe métricas via PerformanceObserver. Exemplo prático de coleta:

// web-vitals-collector.js
function collectWebVitals(callback) {
  // LCP
  const lcpObserver = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    callback('LCP', lastEntry.startTime);
  });
  lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

  // FID
  const fidObserver = new PerformanceObserver((list) => {
    list.getEntries().forEach((entry) => {
      callback('FID', entry.processingStart - entry.startTime);
    });
  });
  fidObserver.observe({ type: 'first-input', buffered: true });

  // CLS
  let clsValue = 0;
  const clsObserver = new PerformanceObserver((list) => {
    list.getEntries().forEach((entry) => {
      if (!entry.hadRecentInput) {
        clsValue += entry.value;
      }
    });
    callback('CLS', clsValue);
  });
  clsObserver.observe({ type: 'layout-shift', buffered: true });
}

Para navegadores legados, utilize polyfills como o pacote web-vitals que abstrai essas complexidades.

3. Integração com React: hooks e componentes para monitoramento

Crie um hook customizado para integrar Web Vitals ao ciclo de vida do React:

// useWebVitals.js
import { useEffect, useRef } from 'react';

export function useWebVitals(onReport) {
  const callbackRef = useRef(onReport);
  callbackRef.current = onReport;

  useEffect(() => {
    const handleVital = (metric) => {
      callbackRef.current(metric);
    };

    // Import dinâmico do pacote web-vitals
    import('web-vitals').then(({ onLCP, onFID, onCLS }) => {
      onLCP(handleVital);
      onFID(handleVital);
      onCLS(handleVital);
    });
  }, []);
}

Componente WebVitalsReporter para enviar dados ao backend:

// WebVitalsReporter.js
import { useWebVitals } from './useWebVitals';

function WebVitalsReporter({ endpoint }) {
  useWebVitals((metric) => {
    const body = {
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      url: window.location.pathname,
      timestamp: Date.now(),
    };

    // Envio via Beacon API para não bloquear o descarregamento da página
    if (navigator.sendBeacon) {
      navigator.sendBeacon(endpoint, JSON.stringify(body));
    } else {
      fetch(endpoint, {
        method: 'POST',
        body: JSON.stringify(body),
        headers: { 'Content-Type': 'application/json' },
        keepalive: true,
      });
    }
  });

  return null; // Componente não renderiza nada visualmente
}

4. Enviando métricas para o backend com Node.js

Endpoint REST no Express para receber e validar métricas:

// server.js
const express = require('express');
const { body, validationResult } = require('express-validator');
const Queue = require('bull');

const app = express();
app.use(express.json());

const vitalsQueue = new Queue('web-vitals', 'redis://localhost:6379');

app.post('/api/vitals',
  [
    body('name').isIn(['LCP', 'FID', 'CLS', 'FCP', 'TTFB']),
    body('value').isNumeric(),
    body('rating').isIn(['good', 'needs-improvement', 'poor']),
  ],
  async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }

    // Adiciona à fila para processamento assíncrono
    await vitalsQueue.add(req.body);
    res.status(202).json({ status: 'accepted' });
  }
);

5. Análise e visualização dos dados coletados

Processamento das métricas no backend para cálculo de percentis:

// vitals-processor.js
const { Worker } = require('bull');

const vitalsWorker = new Worker('web-vitals', async (job) => {
  const { name, value, url, timestamp } = job.data;

  // Armazena no banco (exemplo com MongoDB)
  await Metric.create({ name, value, url, timestamp });
});

// Rota para dados agregados
app.get('/api/vitals/summary', async (req, res) => {
  const metrics = await Metric.aggregate([
    { $match: { name: 'LCP' } },
    { $group: {
        _id: null,
        p75: { $percentile: [0.75, '$value'] },
        p95: { $percentile: [0.95, '$value'] },
        count: { $sum: 1 }
    }}
  ]);
  res.json(metrics);
});

6. Otimização orientada por Web Vitals no ecossistema React + Node.js

Para melhorar LCP:
- Lazy loading de imagens com loading="lazy"
- Pré-carregamento de fontes críticas via <link rel="preload">
- SSR com Next.js ou Gatsby para renderizar conteúdo no servidor

Para reduzir CLS:
- Reserve espaço para anúncios e imagens com atributos width e height
- Use font-display: swap para web fonts
- Evite inserir conteúdo dinâmico acima do conteúdo já renderizado

Para melhorar FID/INP:
- Code splitting com React.lazy() e Suspense
- Debouncing em eventos de input e scroll
- Otimização de bundles com tree shaking e code splitting

7. Monitoramento contínuo e alertas

Configuração de alertas no backend com agendamento:

// alerts.js
const cron = require('node-cron');
const { sendSlackAlert } = require('./notifications');

cron.schedule('*/5 * * * *', async () => {
  const metrics = await Metric.aggregate([
    { $match: { name: 'LCP', timestamp: { $gte: Date.now() - 3600000 } } },
    { $group: { _id: null, p95: { $percentile: [0.95, '$value'] } } }
  ]);

  if (metrics[0] && metrics[0].p95 > 4000) {
    await sendSlackAlert(`⚠️ LCP p95 ultrapassou 4s: ${metrics[0].p95}ms`);
  }
});

8. Caso prático: integração completa em uma aplicação React + Node.js

WebVitalsProvider que envolve toda a aplicação:

// WebVitalsProvider.js
import { useWebVitals } from './useWebVitals';

export function WebVitalsProvider({ children, endpoint }) {
  useWebVitals((metric) => {
    // Envia para backend e também para Google Analytics 4
    if (window.gtag) {
      gtag('event', 'web_vitals', {
        event_category: metric.name,
        value: Math.round(metric.value),
        label: metric.rating,
      });
    }

    fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(metric),
      keepalive: true,
    });
  });

  return children;
}

Uso no App principal:

// App.js
function App() {
  return (
    <WebVitalsProvider endpoint="/api/vitals">
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/product/:id" element={<Product />} />
        </Routes>
      </Router>
    </WebVitalsProvider>
  );
}

Para testes de carga simulando usuários reais:

npx autocannon -c 100 -d 30 http://localhost:3000

Isso gerará tráfego que permite validar se as métricas coletadas refletem a experiência real sob carga.


Referências