Performance em aplicações web

[127] Performance em aplicações web

É fácil otimizar a coisa errada, e por isso a ordem importa: medir antes. O artigo apresenta as Core Web Vitals e as ferramentas que as leem, e então percorre os gargalos que de fato aparecem — bundle grande, renderização desnecessária, lista longa sem virtualização, imagem pesada, consulta sem índice e o N+1.
Javascript

35 min de leitura

Uma aplicação lenta é uma aplicação quebrada. Estudos da Google mostram que cada 100ms de atraso no carregamento reduz conversões em 1%. Após 3 segundos de espera, mais da metade dos usuários abandona a página. Performance não é um detalhe estético — é uma funcionalidade central.

O problema com otimização é que é fácil otimizar a coisa errada. Desenvolvedores frequentemente passam horas ajustando detalhes que impactam milissegundos enquanto ignoram gargalos que custam segundos. Por isso, a regra número um de performance é: meça primeiro, otimize depois.

Este artigo ensina como medir corretamente, onde os gargalos mais comuns aparecem, e as técnicas mais eficazes para eliminá-los — tanto no front-end React quanto no back-end Node.js.

Medindo performance — as métricas que importam

Antes de otimizar qualquer coisa, precisamos estabelecer o que estamos medindo. O Google definiu as Core Web Vitals como as métricas fundamentais de experiência do usuário.

LCP (Largest Contentful Paint) mede quanto tempo leva para o maior elemento visível da página ser renderizado. Representa quando o usuário percebe que a página "carregou". A meta é abaixo de 2,5 segundos.

INP (Interaction to Next Paint) mede quanto tempo o navegador leva para responder às interações do usuário — clique, toque, tecla. A meta é abaixo de 200 ms; entre 200 e 500 precisa melhorar, e acima disso é ruim.

Ele substituiu o antigo FID (First Input Delay) como métrica oficial em março de 2024, e a diferença entre os dois não é só de nome: o FID media apenas o atraso da primeira interação, e apenas até o início do processamento — sua meta era 100 ms. O INP observa todas as interações da visita e mede o ciclo completo, até a tela ser de fato atualizada. É uma métrica bem mais difícil de satisfazer, e páginas que tinham FID excelente costumam ter INP medianos — justamente porque o gargalo raramente está no primeiro clique.

CLS (Cumulative Layout Shift) mede a estabilidade visual — quanto os elementos da página se movem enquanto carregam. Nada mais frustrante do que clicar em um botão que se moveu. A meta é abaixo de 0,1.

// Medindo Core Web Vitals no React com a biblioteca oficial
// npm install web-vitals

// src/utils/webVitals.js
// Atenção à versão: o onFID foi REMOVIDO na versão 5 da biblioteca, porque a
// métrica foi aposentada. Importá-lo hoje quebra o build. Ficou o onINP.
import { onCLS, onINP, onLCP, onTTFB, onFCP } from 'web-vitals';

// Função que envia as métricas para um serviço de analytics
// Em produção, você enviaria para o Google Analytics, Datadog, etc.
function reportarMetrica(metrica) {
  console.log(`[Web Vitals] ${metrica.name}: ${Math.round(metrica.value)}ms`);

  // Exemplo de envio para o Google Analytics 4
  if (window.gtag) {
    window.gtag('event', metrica.name, {
      event_category: 'Web Vitals',
      event_label: metrica.id,
      value: Math.round(
        // LCP e TTFB são em ms — CLS é adimensional (multiplica por 1000 para GA)
        metrica.name === 'CLS' ? metrica.value * 1000 : metrica.value
      ),
      non_interaction: true, // não conta como bounce no GA
    });
  }
}

// Registra todos os observers das métricas
export function iniciarMonitoramento() {
  onCLS(reportarMetrica);   // Cumulative Layout Shift
  onINP(reportarMetrica);   // Interaction to Next Paint (substituiu o FID)
  onFCP(reportarMetrica);   // First Contentful Paint (diagnóstico, não é Core)
  onLCP(reportarMetrica);   // Largest Contentful Paint
  onTTFB(reportarMetrica);  // Time to First Byte (velocidade do servidor)
}
// src/main.jsx — ativa o monitoramento em produção
import { iniciarMonitoramento } from './utils/webVitals';

ReactDOM.createRoot(document.getElementById('root')).render(<App />);

// Só monitora em produção — em dev causaria ruído desnecessário
if (import.meta.env.PROD) {
  iniciarMonitoramento();
}

Ferramentas de medição

Antes de escrever uma linha de otimização, use estas ferramentas para entender onde estão os gargalos reais.

Lighthouse (Google Chrome DevTools)
  → Análise completa: performance, acessibilidade, SEO, boas práticas
  → Abre DevTools → aba Lighthouse → Generate report
  → Teste em modo incógnito (sem extensões interferindo)
  → Simula conexão lenta (3G) para cenários reais

Chrome DevTools — Network
  → Waterfall de carregamento: veja o que está bloqueando
  → Filtre por JS, CSS, Fetch para analisar cada tipo
  → "Disable cache" para simular primeira visita
  → Throttling para simular 3G ou 4G lento

Chrome DevTools — Performance
  → Grava a execução e mostra flame chart
  → Identifica funções lentas e long tasks (>50ms)
  → Mostra quando o main thread está bloqueado

Chrome DevTools — Coverage
  → Mostra qual porcentagem do JS/CSS está sendo usada
  → Código não usado = bundle desnecessariamente grande

PageSpeed Insights (pagespeed.web.dev)
  → Usa dados reais de usuários do Chrome (CrUX data)
  → Distinção entre lab data e field data
  → Grátis e não requer instalação

WebPageTest (webpagetest.org)
  → Testa de locais específicos (São Paulo, por exemplo)
  → Comparação antes/depois de otimizações
  → Relatórios detalhados com filmstrip visual

Performance no front-end React

Bundle size — o problema mais comum

O maior impacto em performance de front-end geralmente vem do tamanho do JavaScript enviado ao navegador. JavaScript precisa ser baixado, parseado e executado — é o recurso mais caro por byte.

// vite.config.js — analisando e otimizando o bundle
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// npm install -D rollup-plugin-visualizer
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    // Gera stats.html após o build com mapa visual do bundle
    // Abra o arquivo para ver quais bibliotecas ocupam mais espaço
    visualizer({
      open: true,          // abre automaticamente no browser após build
      gzipSize: true,      // mostra tamanho após gzip (mais realista)
      brotliSize: true,    // e após brotli
    }),
  ],

  build: {
    rollupOptions: {
      output: {
        // Code splitting manual — agrupa bibliotecas em chunks lógicos
        // Benefício: se react-router não mudar, o browser usa o cache anterior
        manualChunks: {
          // Bibliotecas que raramente mudam ficam em cache por mais tempo
          'vendor-react': ['react', 'react-dom'],
          'vendor-router': ['react-router-dom'],
          'vendor-query': ['@tanstack/react-query'],
          'vendor-store': ['zustand'],
        },
      },
    },
  },
});
# Após npm run build, examine o output:
# dist/assets/index-[hash].js       → código da aplicação
# dist/assets/vendor-react-[hash].js → react e react-dom
# dist/assets/produtos-[hash].js    → página de produtos (lazy)

# Analise tamanhos:
ls -lh dist/assets/*.js

# Verifique tamanhos gzipados (mais realista — servidores comprimem):
gzip -k dist/assets/*.js && ls -lh dist/assets/*.js.gz

Lazy loading e code splitting

Já vimos lazy loading com React Router no Módulo 6. Aqui vamos mais fundo — lazy loading pode ser aplicado a qualquer componente pesado, não apenas a páginas.

// Lazy loading de componente pesado que não aparece imediatamente

// ❌ Importa o editor de texto RICO junto com o bundle principal
// (bibliotecas como TipTap, QuillJS, Monaco têm centenas de KB)
import RichTextEditor from './RichTextEditor';

function FormularioProduto() {
  return (
    <div>
      <input type="text" />
      <RichTextEditor />  {/* carregado mesmo em /login */}
    </div>
  );
}

// ✅ Só carrega o editor quando o componente for renderizado
const RichTextEditor = lazy(() => import('./RichTextEditor'));

function FormularioProduto() {
  return (
    <div>
      <input type="text" />
      <Suspense fallback={<div className="editor-skeleton" />}>
        <RichTextEditor />
      </Suspense>
    </div>
  );
}

// Lazy loading condicional — só carrega se o usuário é admin
function Dashboard() {
  const eAdmin = useAuthStore((s) => s.usuario?.papel === 'admin');
  // O PainelAdmin só é importado se eAdmin for true
  const PainelAdmin = eAdmin ? lazy(() => import('./PainelAdmin')) : null;

  return (
    <div>
      <ResumoGeral />
      {eAdmin && PainelAdmin && (
        <Suspense fallback={<p>Carregando painel...</p>}>
          <PainelAdmin />
        </Suspense>
      )}
    </div>
  );
}

Otimizando re-renders — React.memo, useMemo, useCallback

Re-renders desnecessários são o gargalo de runtime mais comum em aplicações React. O problema é que eles são silenciosos — você não vê na tela, mas o browser está trabalhando à toa.

// Instalando o React DevTools Profiler para identificar re-renders
// 1. Instale a extensão React DevTools no Chrome
// 2. Abra DevTools → aba Profiler
// 3. Clique em "Record" → interaja com a página → pare a gravação
// 4. Veja quais componentes re-renderizaram e por quê

// ── IDENTIFICANDO O PROBLEMA ────────────────────────
function ListaProdutos({ produtos, onRemover }) {
  console.count('ListaProdutos renderizou'); // debug temporário

  return (
    <ul>
      {produtos.map((p) => (
        // CardProduto re-renderiza toda vez que ListaProdutos re-renderiza
        // mesmo que as props do card não tenham mudado
        <CardProduto key={p.id} produto={p} onRemover={onRemover} />
      ))}
    </ul>
  );
}

// ── SOLUÇÃO 1: React.memo ───────────────────────────
// memo() faz um shallow comparison das props
// Se as props não mudaram (mesma referência), pula a re-renderização
const CardProduto = memo(function CardProduto({ produto, onRemover }) {
  console.count(`CardProduto ${produto.id} renderizou`);
  return (
    <li>
      {produto.nome} — R$ {produto.preco}
      <button onClick={() => onRemover(produto.id)}>Remover</button>
    </li>
  );
});

// ── SOLUÇÃO 2: useCallback para estabilizar funções ─
// Sem useCallback, onRemover é uma nova função a cada render
// → memo() percebe que a prop mudou → re-renderiza mesmo assim

function PaginaProdutos() {
  const [produtos, setProdutos] = useState([...]);
  const [outraCoisa, setOutraCoisa] = useState(0);

  // ❌ Nova referência a cada render — memo() não adianta
  const handleRemover = (id) => {
    setProdutos((prev) => prev.filter((p) => p.id !== id));
  };

  // ✅ Mesma referência entre renders — memo() funciona
  const handleRemover = useCallback((id) => {
    setProdutos((prev) => prev.filter((p) => p.id !== id));
  }, []); // [] porque usa padrão funcional do setState

  return (
    <div>
      <button onClick={() => setOutraCoisa((n) => n + 1)}>
        Clique: {outraCoisa}
        {/* Clicar aqui NÃO vai re-renderizar os CartõesProduto */}
      </button>
      <ListaProdutos produtos={produtos} onRemover={handleRemover} />
    </div>
  );
}

// ── SOLUÇÃO 3: useMemo para cálculos derivados ──────
function EstatisticasProdutos({ produtos }) {
  // ❌ Recalcula em todo render — mesmo quando produtos não mudou
  const estatisticas = {
    total: produtos.length,
    precoMedio: produtos.reduce((s, p) => s + p.preco, 0) / produtos.length,
    maisCaros: produtos.filter((p) => p.preco > 1000).length,
    semEstoque: produtos.filter((p) => p.estoque === 0).length,
  };

  // ✅ Só recalcula quando produtos muda
  const estatisticasMemo = useMemo(() => ({
    total: produtos.length,
    precoMedio: produtos.reduce((s, p) => s + p.preco, 0) / produtos.length,
    maisCaros: produtos.filter((p) => p.preco > 1000).length,
    semEstoque: produtos.filter((p) => p.estoque === 0).length,
  }), [produtos]);

  return (
    <div>
      <p>Total: {estatisticasMemo.total}</p>
      <p>Preço médio: R$ {estatisticasMemo.precoMedio.toFixed(2)}</p>
    </div>
  );
}

Otimizando listas longas — virtualização

Renderizar milhares de itens no DOM ao mesmo tempo é lento. A virtualização renderiza apenas os itens visíveis na tela — o resto é "simulado" com espaço vazio.

// npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

function ListaVirtualizada({ itens }) {
  // Referência ao elemento pai (o container que tem scroll)
  const containerRef = useRef(null);

  const virtualizador = useVirtualizer({
    count: itens.length,         // total de itens
    getScrollElement: () => containerRef.current,  // elemento com scroll
    estimateSize: () => 72,      // altura estimada de cada item em px
    overscan: 5,                 // renderiza 5 itens extras acima/abaixo da viewport
    // (evita flash de conteúdo ao rolar rapidamente)
  });

  return (
    // Container com altura fixa e overflow-y: auto
    <div
      ref={containerRef}
      style={{ height: '600px', overflowY: 'auto' }}
    >
      {/*
        Div interna com altura total calculada pelo virtualizador
        Isso cria o espaço de scroll correto sem renderizar todos os itens
      */}
      <div style={{ height: `${virtualizador.getTotalSize()}px`, position: 'relative' }}>
        {virtualizador.getVirtualItems().map((itemVirtual) => {
          const item = itens[itemVirtual.index];
          return (
            <div
              key={itemVirtual.key}
              // Posiciona cada item virtualmente no lugar correto
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${itemVirtual.size}px`,
                transform: `translateY(${itemVirtual.start}px)`,
              }}
            >
              <ItemProduto produto={item} />
            </div>
          );
        })}
      </div>
    </div>
  );
}
// Com 10.000 itens: sem virtualização → 10.000 nós no DOM
// Com virtualização → ~15 nós no DOM → diferença brutal de performance

Imagens — o maior ofensor de LCP

Imagens mal otimizadas são a causa número um de LCP alto. As técnicas são simples mas impactantes.

// ── LAZY LOADING DE IMAGENS ─────────────────────────
// O atributo loading="lazy" é suportado por todos os browsers modernos
// A imagem só é baixada quando está perto de entrar na viewport

// ❌ Baixa todas as imagens da lista imediatamente
function CardProduto({ produto }) {
  return (
    <div>
      <img src={produto.imagem} alt={produto.nome} />
    </div>
  );
}

// ✅ Lazy loading nativo — zero JavaScript necessário
function CardProduto({ produto }) {
  return (
    <div>
      <img
        src={produto.imagem}
        alt={produto.nome}
        loading="lazy"       // browser decide quando baixar
        decoding="async"     // decodifica sem bloquear o main thread
        width={300}          // sempre especifique dimensões
        height={200}         // evita CLS (layout shift ao carregar)
      />
    </div>
  );
}

// ── A IMAGEM HERO (LCP) DEVE SER PRIORITÁRIA ────────
// A imagem principal da página (o LCP) NÃO deve ter lazy loading
// Ao contrário — deve ter fetchpriority="high"
function HeroProduto({ produto }) {
  return (
    <img
      src={produto.imagemPrincipal}
      alt={produto.nome}
      fetchPriority="high"   // instrui o browser a baixar primeiro
      decoding="async"
      width={800}
      height={600}
    />
  );
}

// ── FORMATOS MODERNOS ────────────────────────────────
// WebP e AVIF são muito menores que JPEG/PNG com mesma qualidade
// Use a tag <picture> para servir o formato certo para cada browser

function ImagemOtimizada({ src, alt, width, height }) {
  // Remove a extensão e gera caminhos para cada formato
  const base = src.replace(/.(jpg|jpeg|png)$/i, '');

  return (
    <picture>
      {/* Browser tenta AVIF primeiro (menor, mais moderno) */}
      <source srcSet={`${base}.avif`} type="image/avif" />
      {/* Fallback para WebP (amplo suporte) */}
      <source srcSet={`${base}.webp`} type="image/webp" />
      {/* Fallback final para JPEG/PNG (todos os browsers) */}
      <img
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading="lazy"
        decoding="async"
      />
    </picture>
  );
}

Performance no back-end Node.js

Otimizando queries ao MongoDB

O banco de dados é o gargalo mais comum em APIs. Consultas sem índices, dados em excesso e múltiplos round-trips são os culpados mais frequentes.

// ── ÍNDICES — a otimização de maior impacto ──────────
// Uma query sem índice faz full collection scan — lê TODOS os documentos
// Com índice, encontra os documentos diretamente — diferença de 100x ou mais

// Verificando se suas queries usam índices
// No MongoDB Compass ou mongosh:
// db.tarefas.find({ usuario: ObjectId(...) }).explain('executionStats')
// Procure por: "IXSCAN" (usa índice) vs "COLLSCAN" (não usa — problema!)

// No Mongoose, defina índices no schema:
const tarefaSchema = new Schema({
  titulo: String,
  status: String,
  prioridade: String,
  usuario: { type: Schema.Types.ObjectId, ref: 'Usuario' },
  criadoEm: Date,
});

// Índice composto — otimiza a query mais comum da aplicação:
// Tarefa.find({ usuario: id, status: 'pendente' }).sort({ criadoEm: -1 })
// O índice cobre exatamente este padrão de consulta
tarefaSchema.index({ usuario: 1, status: 1, criadoEm: -1 });

// Índice de texto — otimiza buscas por texto livre
tarefaSchema.index({ titulo: 'text', descricao: 'text' });

// ── LEAN() — consultas somente leitura mais rápidas ─
// Por padrão, o Mongoose transforma cada documento em um objeto com
// métodos, getters, setters e toda a maquinaria do ODM.
// .lean() retorna plain JavaScript objects — muito mais rápido

// ❌ Sem .lean() — cria objetos Mongoose completos (mais memória, mais CPU)
const tarefas = await Tarefa.find({ usuario: id });

// ✅ Com .lean() — retorna objetos JS simples
// Use sempre que não precisar de métodos de instância (save, etc.)
const tarefas = await Tarefa.find({ usuario: id }).lean();

// ── SELECT — busque apenas os campos necessários ─────
// Buscar documentos completos quando você precisa de 3 campos
// desperdiça largura de banda e memória

// ❌ Retorna todos os campos (pode ser centenas de KB por documento)
const tarefas = await Tarefa.find({ usuario: id });

// ✅ Retorna apenas os campos necessários para a listagem
const tarefas = await Tarefa
  .find({ usuario: id })
  .select('titulo status prioridade criadoEm')
  .lean();

// ── PARALELISMO — queries independentes em paralelo ─
// Se duas queries não dependem uma da outra, rode-as juntas

// ❌ Sequencial — a segunda espera a primeira terminar
async function estatisticasDashboard(usuarioId) {
  const totalTarefas = await Tarefa.countDocuments({ usuario: usuarioId });
  const totalProdutos = await Produto.countDocuments({ ativo: true });
  // tempo total = tempo(tarefas) + tempo(produtos)
  return { totalTarefas, totalProdutos };
}

// ✅ Paralelo — ambas rodam ao mesmo tempo
async function estatisticasDashboard(usuarioId) {
  // Promise.all executa ambas simultaneamente
  const [totalTarefas, totalProdutos] = await Promise.all([
    Tarefa.countDocuments({ usuario: usuarioId }),
    Produto.countDocuments({ ativo: true }),
  ]);
  // tempo total = max(tempo(tarefas), tempo(produtos))
  return { totalTarefas, totalProdutos };
}

// ── PAGINAÇÃO — nunca busque tudo de uma vez ─────────

// ❌ Retorna TODOS os documentos — perigoso com grandes coleções
const todasAsTarefas = await Tarefa.find({ usuario: id });

// ✅ Paginação com skip/limit
const pagina = Number(req.query.pagina) || 1;
const porPagina = Math.min(Number(req.query.por_pagina) || 10, 50);
const skip = (pagina - 1) * porPagina;

const [tarefas, total] = await Promise.all([
  Tarefa.find({ usuario: id })
    .sort({ criadoEm: -1 })
    .skip(skip)
    .limit(porPagina)
    .lean(),
  Tarefa.countDocuments({ usuario: id }),
]);

Cache — evitando trabalho repetido

Cache é a otimização de maior retorno para dados que não mudam a cada requisição. A ideia é simples: calcule uma vez, sirva muitas vezes.

// Cache em memória com node-cache (para dados de curta duração)
// npm install node-cache
const NodeCache = require('node-cache');

// TTL de 5 minutos — dados expiram e são recalculados automaticamente
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 });

async function buscarEstatisticasComCache(usuarioId) {
  const chave = `estatisticas:${usuarioId}`;

  // Tenta o cache primeiro — O(1), instantâneo
  const emCache = cache.get(chave);
  if (emCache) {
    console.log('[Cache] HIT:', chave);
    return emCache;
  }

  // Cache miss — calcula do zero (aggregation custosa)
  console.log('[Cache] MISS:', chave);
  const dados = await Tarefa.aggregate([
    { $match: { usuario: mongoose.Types.ObjectId(usuarioId) } },
    {
      $group: {
        _id: '$status',
        total: { $sum: 1 },
      },
    },
  ]);

  // Salva no cache para próximas requisições
  cache.set(chave, dados);
  return dados;
}

// Invalidação do cache quando os dados mudam
async function criarTarefa(usuarioId, dados) {
  const tarefa = await Tarefa.create({ ...dados, usuario: usuarioId });

  // Remove o cache do usuário — será recalculado na próxima requisição
  cache.del(`estatisticas:${usuarioId}`);

  return tarefa;
}

Compressão — reduzindo tráfego de rede

Compressão gzip ou brotli reduz o tamanho das respostas HTTP em 60-80% para texto (JSON, HTML, CSS). É uma das otimizações mais fáceis de implementar.

// npm install compression
const compression = require('compression');

app.use(
  compression({
    // Só comprime respostas maiores que 1KB
    // Respostas pequenas não se beneficiam da compressão
    threshold: 1024,

    // Nível de compressão: 1 (rápido, menos compressão) a 9 (lento, mais compressão)
    // 6 é o padrão — bom equilíbrio entre velocidade e compressão
    level: 6,

    // Não comprime streams de vídeo ou imagens (já são binários comprimidos)
    filter: (req, res) => {
      if (req.headers['x-no-compression']) return false;
      return compression.filter(req, res);
    },
  })
);

// A compressão é transparente — o cliente recebe dados menores,
// descomprime automaticamente. Você não muda nada no código das rotas.

Monitorando performance em produção

Saber que sua aplicação ficou lenta após um deploy é inestimável. O Node.js tem APIs nativas para medir performance.

// src/middlewares/performance.js
// Middleware que mede o tempo de cada requisição e loga as lentas

function monitorarPerformance(req, res, next) {
  // performance.now() tem precisão de submilissegundo
  const inicio = performance.now();

  // Intercepta o momento em que a resposta é finalizada
  res.on('finish', () => {
    const duracaoMs = performance.now() - inicio;

    // Loga apenas requisições lentas (> 500ms) para não poluir os logs
    if (duracaoMs > 500) {
      console.warn(
        `[Slow Request] ${req.method} ${req.path} — ${duracaoMs.toFixed(2)}ms`
      );
    }

    // Adiciona header de timing para debugging no DevTools
    // Visível em DevTools → Network → Timing
    if (process.env.NODE_ENV === 'development') {
      res.setHeader('Server-Timing', `total;dur=${duracaoMs.toFixed(2)}`);
    }
  });

  next();
}

module.exports = { monitorarPerformance };
// Profiling de funções críticas com console.time
// Use durante desenvolvimento para medir operações específicas

async function listarComFiltros(filtros) {
  console.time('listarComFiltros:query');

  const resultado = await Produto
    .find(filtros)
    .sort('-criadoEm')
    .limit(10)
    .lean();

  console.timeEnd('listarComFiltros:query');
  // Output: listarComFiltros:query: 45.234ms

  return resultado;
}

Checklist de performance

Front-end
─────────────────────────────────────────────────────────
[ ] Lighthouse score > 90 em Performance
[ ] Lazy loading em todas as páginas (React.lazy + Suspense)
[ ] Code splitting manual para bibliotecas grandes (manualChunks)
[ ] Imagens com loading="lazy" (exceto hero/LCP)
[ ] Imagem LCP com fetchPriority="high"
[ ] Dimensões explícitas em todas as imagens (evita CLS)
[ ] React.memo em componentes de lista que recebem callbacks
[ ] useMemo para cálculos custosos derivados de estado
[ ] useCallback para funções passadas como props a componentes memoizados
[ ] Virtualização para listas > 100 itens
[ ] Web Vitals monitorados em produção

Back-end
─────────────────────────────────────────────────────────
[ ] Índices em todos os campos usados em find(), sort(), match()
[ ] .explain('executionStats') confirma IXSCAN (não COLLSCAN)
[ ] .lean() em todas as queries de leitura
[ ] .select() buscando apenas campos necessários
[ ] Queries independentes rodando com Promise.all()
[ ] Paginação em todas as listagens (nunca busca tudo)
[ ] Cache para dados custosos e pouco mutáveis
[ ] Compressão gzip/brotli ativa
[ ] Middleware de slow requests monitorando produção

Tarefa para você

Aplique as otimizações na SPA do Módulo 6:

# 1. Meça o estado atual com Lighthouse
#    Abra a SPA em produção em uma aba anônima
#    Gere um relatório Lighthouse e anote os scores
#    Guarde o screenshot para comparação pós-otimização

# 2. Analise o bundle com rollup-plugin-visualizer
#    npm install -D rollup-plugin-visualizer
#    Adicione ao vite.config.js e execute npm run build
#    Identifique a maior biblioteca no mapa visual

# 3. Implemente virtualização na lista de produtos
#    Se a lista tem mais de 50 itens, a diferença é visível
#    npm install @tanstack/react-virtual

# 4. Adicione monitoramento de slow requests na API
#    Rode a API com NODE_ENV=development
#    Faça requisições e observe o header Server-Timing no DevTools

# 5. Adicione .explain() a todas as queries principais:
#    Tarefa.find({ usuario: id }).explain('executionStats')
#    Verifique se todas usam IXSCAN
#    Adicione índices para as que usam COLLSCAN

# 6. Meça novamente com Lighthouse após as otimizações
#    Compare os scores com os do passo 1
#    Documente as melhorias alcançadas
Ver solução — as otimizações medidas — bundle, virtualização, Server-Timing e índices
// 1 e 6 — LIGHTHOUSE
//
// Chrome → DevTools → Lighthouse → Analyze page load, em aba anônima (extensão
// suja a medição). Guarde o relatório dos dois momentos: sem o "antes", o
// "depois" não prova nada.
//
// Meça a produção, não o `npm run dev`: em desenvolvimento o Vite serve módulos
// sem minificar e o score não significa nada.

// ---- vite.config.mjs
// 2 — ANALISANDO O BUNDLE
//
// npm install -D rollup-plugin-visualizer
//
// Repare no nome do arquivo: `vite.config.mjs`, não `.js`. O
// rollup-plugin-visualizer é ESM-only, e num projeto com
// `"type": "commonjs"` no package.json o Vite tenta carregá-lo com require e
// falha com "This package is ESM only".
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  plugins: [
    react(),
    visualizer({ filename: "dist/stats.html", gzipSize: true, brotliSize: true }),
  ],
  build: {
    sourcemap: false,
    rollupOptions: {
      output: {
        // Forma de FUNÇÃO. O Vite 8 roda em Rolldown, e lá a forma de
        // objeto (`{ "vendor": ["react"] }`) do Rollup é recusada:
        // "manualChunks is not a function".
        manualChunks(id) {
          if (!id.includes("node_modules")) return;
          if (id.includes("react-router")) return "router";
          if (id.includes("@tanstack")) return "tanstack";
          if (id.includes("/react/") || id.includes("/react-dom/")) return "react-vendor";
          return "vendor";
        },
      },
    },
  },
});

// ---- o resultado real do `npm run build`
//
//   dist/assets/react-vendor-*.js   189.59 kB │ gzip: 59.61 kB   ← a maior
//   dist/assets/router-*.js          39.57 kB │ gzip: 14.35 kB
//   dist/assets/tanstack-*.js        25.08 kB │ gzip:  7.53 kB
//   dist/assets/index-*.js            5.72 kB │ gzip:  2.16 kB
//   dist/assets/Produtos-*.js         0.73 kB │ gzip:  0.46 kB
//   dist/assets/Login-*.js            0.68 kB │ gzip:  0.43 kB
//   ... mais 8 chunks de página, entre 0.19 e 0.47 kB
//   ✓ built in 968ms
//
// Duas leituras. A maior biblioteca é o React em si — e contra ela não há
// otimização, só troca de framework; o que dá para fazer é não baixá-la de
// novo a cada deploy, e é isso que o chunk separado garante (o hash dele não
// muda quando o seu código muda). E cada página virou um arquivo de menos de
// 1 kB: é o lazy loading do artigo anterior, visível no build.

// ---- src/componentes/ListaVirtual.jsx
// 3 — VIRTUALIZAÇÃO
//
// npm install @tanstack/react-virtual
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

export function ListaVirtual({ produtos, altura = 400, alturaItem = 40, larguraInicial = 300 }) {
  const containerRef = useRef(null);

  const virtualizador = useVirtualizer({
    count: produtos.length,
    getScrollElement: () => containerRef.current,
    estimateSize: () => alturaItem,
    // 5 itens de folga acima e abaixo: sem overscan, rolar rápido mostra
    // faixas em branco enquanto o React monta os nós.
    overscan: 5,
    // Medida inicial, antes de o layout existir. Vale para SSR e para
    // teste em jsdom: sem ela o virtualizador acha que a janela tem 0px
    // de altura e não renderiza item nenhum.
    initialRect: { width: larguraInicial, height: altura },
  });

  return (
    <div
      ref={containerRef}
      data-testid="janela"
      style={{ height: altura, overflow: "auto" }}
    >
      {/* O div interno tem a altura TOTAL da lista — é ele que faz a barra
          de rolagem ter o tamanho certo, mesmo sem os itens existirem. */}
      <div style={{ height: virtualizador.getTotalSize(), position: "relative" }}>
        {virtualizador.getVirtualItems().map((item) => (
          <div
            key={item.key}
            data-indice={item.index}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: item.size,
              transform: `translateY(${item.start}px)`,
            }}
          >
            {produtos[item.index].nome}
          </div>
        ))}
      </div>
    </div>
  );
}

// ---- testes/virtual127.test.jsx
import { render, screen } from "@testing-library/react";
import { ListaVirtual } from "../src/componentes/ListaVirtual";

const PRODUTOS = Array.from({ length: 5000 }, (_, i) => ({ _id: String(i), nome: `Produto ${i}` }));

// O jsdom não tem motor de layout: TODA medida é zero. O virtualizador lê
// `offsetHeight` da janela de rolagem (não `getBoundingClientRect`) — e com
// altura 0 ele conclui, corretamente, que não cabe nenhum item. Testar
// componente virtualizado exige fingir o layout; não há como contornar.
beforeAll(() => {
  Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
    configurable: true,
    get() {
      return Number.parseInt(this.style.height, 10) || 400;
    },
  });
  Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
    configurable: true,
    get() {
      return 300;
    },
  });
});

describe("3 — virtualização", () => {
  it("renderiza uma dúzia de nós, não os 5000", () => {
    render(<ListaVirtual produtos={PRODUTOS} />);
    const renderizados = document.querySelectorAll("[data-indice]");

    expect(PRODUTOS.length).toBe(5000);
    expect(renderizados.length).toBeGreaterThan(0);
    expect(renderizados.length).toBeLessThan(40);
  });

  it("a barra de rolagem tem a altura da lista inteira", () => {
    render(<ListaVirtual produtos={PRODUTOS} altura={400} alturaItem={40} />);
    const interno = screen.getByTestId("janela").firstChild;
    expect(interno).toHaveStyle({ height: "200000px" });   // 5000 × 40
  });

  it("começa pelo primeiro item", () => {
    render(<ListaVirtual produtos={PRODUTOS} />);
    expect(screen.getByText("Produto 0")).toBeInTheDocument();
    expect(screen.queryByText("Produto 4999")).not.toBeInTheDocument();
  });
});

// ---- src/middlewares/serverTiming.js
// 4 — SLOW REQUESTS E O HEADER Server-Timing
// 4 — Server-Timing: o tempo de cada fase chega ao DevTools do navegador,
// na aba Network → Timing. Sem isso, "a API está lenta" é achismo.
function serverTiming(req, res, next) {
  const inicio = process.hrtime.bigint();
  const marcas = [];

  // req.medir("db", async () => ...) mede um trecho e o publica no header.
  req.medir = async (nome, funcao) => {
    const t0 = process.hrtime.bigint();
    try {
      return await funcao();
    } finally {
      marcas.push({ nome, ms: Number(process.hrtime.bigint() - t0) / 1e6 });
    }
  };

  res.on("finish", () => {
    const total = Number(process.hrtime.bigint() - inicio) / 1e6;
    if (total > Number(process.env.LIMITE_LENTO_MS || 500)) {
      console.warn(
        `[lenta] ${req.method} ${req.originalUrl} — ${total.toFixed(1)}ms ` +
          marcas.map((m) => `${m.nome}=${m.ms.toFixed(1)}ms`).join(" ")
      );
    }
  });

  // O header tem de ser escrito ANTES de res.json(): depois de a resposta
  // sair, setHeader não faz nada (e nem avisa).
  const jsonOriginal = res.json.bind(res);
  res.json = (corpo) => {
    const total = Number(process.hrtime.bigint() - inicio) / 1e6;
    const partes = [
      ...marcas.map((m) => `${m.nome};dur=${m.ms.toFixed(1)}`),
      `total;dur=${total.toFixed(1)}`,
    ];
    if (!res.headersSent) res.setHeader("Server-Timing", partes.join(", "));
    return jsonOriginal(corpo);
  };

  next();
}

module.exports = { serverTiming };

// ---- testes/integracao/performance.test.js
// 5 — .explain(): OS TESTES QUE MEDEM O ÍNDICE
const request = require("supertest");
const express = require("express");
const mongoose = require("mongoose");
const Tarefa = require("../../src/models/Tarefa");
const { serverTiming } = require("../../src/middlewares/serverTiming");

const ID_USUARIO = new mongoose.Types.ObjectId();

async function semear(quantidade) {
  const prioridades = ["baixa", "media", "alta"];
  await Tarefa.insertMany(
    Array.from({ length: quantidade }, (_, i) => ({
      titulo: `Tarefa ${i}`,
      status: i % 3 === 0 ? "concluida" : "pendente",
      prioridade: prioridades[i % 3],
      usuario: ID_USUARIO,
    }))
  );
}

describe("5 — .explain(): IXSCAN contra COLLSCAN", () => {
  beforeEach(async () => {
    await semear(300);
    await Tarefa.syncIndexes();   // garante que os índices do schema existem
  });

  it("a query do dono usa índice (IXSCAN)", async () => {
    const plano = await Tarefa.find({ usuario: ID_USUARIO, status: "pendente" })
      .explain("executionStats");

    const estagio = plano.queryPlanner.winningPlan.inputStage ?? plano.queryPlanner.winningPlan;
    const tipos = JSON.stringify(plano.queryPlanner.winningPlan);

    expect(tipos).toContain("IXSCAN");
    expect(plano.executionStats.totalDocsExamined).toBeLessThanOrEqual(
      plano.executionStats.nReturned * 2
    );
  });

  it("query só por prioridade varre a coleção inteira (COLLSCAN)", async () => {
    const plano = await Tarefa.find({ prioridade: "alta" }).explain("executionStats");

    expect(JSON.stringify(plano.queryPlanner.winningPlan)).toContain("COLLSCAN");
    // A assinatura do problema: examinou 300 para devolver 100.
    expect(plano.executionStats.totalDocsExamined).toBe(300);
    expect(plano.executionStats.nReturned).toBe(100);
  });

  it("criar o índice troca COLLSCAN por IXSCAN e derruba os docs examinados", async () => {
    await Tarefa.collection.createIndex({ prioridade: 1 });

    const plano = await Tarefa.find({ prioridade: "alta" }).explain("executionStats");

    expect(JSON.stringify(plano.queryPlanner.winningPlan)).toContain("IXSCAN");
    expect(plano.executionStats.totalDocsExamined).toBe(100);   // era 300

    await Tarefa.collection.dropIndex({ prioridade: 1 });
  });

  it("regex sem âncora não usa índice nem com ele criado", async () => {
    await Tarefa.collection.createIndex({ titulo: 1 });

    const semAncora = await Tarefa.find({ titulo: { $regex: "efa", $options: "i" } })
      .explain("executionStats");

    // IXSCAN aparece, mas examinando a chave inteira: o índice não ajuda.
    expect(semAncora.executionStats.totalKeysExamined).toBeGreaterThanOrEqual(300);

    await Tarefa.collection.dropIndex({ titulo: 1 });
  });
});

describe("4 — Server-Timing", () => {
  function app() {
    const a = express();
    a.use(serverTiming);
    a.get("/tarefas", async (req, res) => {
      const dados = await req.medir("db", () => Tarefa.find({ usuario: ID_USUARIO }).limit(10).lean());
      res.json({ dados });
    });
    return a;
  }

  it("publica as fases no header", async () => {
    await semear(20);
    const resposta = await request(app()).get("/tarefas");

    expect(resposta.headers["server-timing"]).toMatch(/db;dur=[\d.]+/);
    expect(resposta.headers["server-timing"]).toMatch(/total;dur=[\d.]+/);
  });

  it("avisa no log quando a requisição passa do limite", async () => {
    process.env.LIMITE_LENTO_MS = "0";     // tudo é "lento"
    const aviso = jest.spyOn(console, "warn").mockImplementation(() => {});

    await request(app()).get("/tarefas");

    expect(aviso).toHaveBeenCalledWith(expect.stringContaining("[lenta]"));
    aviso.mockRestore();
    delete process.env.LIMITE_LENTO_MS;
  });
});

// ---- a saída real do explain, sobre 300 tarefas
//
// A) find({ prioridade: "alta" }) — sem índice
//    { estagio: "COLLSCAN", nReturned: 100, totalKeysExamined: 0,   totalDocsExamined: 300 }
//
// B) a MESMA query, depois de createIndex({ prioridade: 1 })
//    { estagio: "FETCH",    nReturned: 100, totalKeysExamined: 100, totalDocsExamined: 100 }
//
// C) find({ usuario, status }) — o índice composto do schema
//    { estagio: "FETCH",    nReturned: 200, totalKeysExamined: 200, totalDocsExamined: 200 }
//
// O número que importa é `totalDocsExamined` contra `nReturned`. Em (A) o Mongo
// leu 300 documentos para devolver 100 — com 300 documentos ninguém percebe;
// com 300 mil, é o suporte tocando o telefone. Em (B), 100 para 100.
//
// Regra de bolso: se `totalDocsExamined` for muito maior que `nReturned`, falta
// índice. Se forem iguais, o índice está fazendo o trabalho.
//
// ---- saída real
// Test Suites: 2 passed (performance + virtualização)
// Tests:       9 passed

A regra que resume o exercício inteiro cabe numa comparação: no explain, olhe totalDocsExamined contra nReturned. Muito maior significa índice faltando; iguais significa índice trabalhando. Com 300 documentos ninguém percebe a diferença — com 300 mil, é o suporte tocando o telefone. E cuidado com o índice que parece resolver e não resolve: $regex sem âncora no começo varre todas as chaves mesmo com o índice criado, porque o índice é ordenado por prefixo e “contém” não tem prefixo. Para busca textual de verdade, índice de texto ou um mecanismo de busca — não regex.

Medir antes de otimizar não é conselho de etiqueta: sem medida, o esforço vai parar no lugar errado com frequência alta. E o grosso do ganho costuma estar em poucos lugares previsíveis — um índice ausente no banco, um bundle que carrega tudo de uma vez, uma imagem enorme no topo da página, uma lista de mil itens renderizada inteira. React.memo e useMemo entram bem depois disso e, aplicados no escuro, costumam custar mais do que rendem.

Fontes e Referências

Exercícios

Exercício 1

O Lighthouse local dá 98 de performance. O PageSpeed Insights, no mesmo site, mostra que os usuários reais falham no INP. Quem está certo?

Lighthouse (local)          PageSpeed Insights (campo)
Performance: 98             INP: 340ms — precisa melhorar
LCP: 1.1s                   LCP: 3.8s — ruim
CLS: 0                      CLS: 0.18 — precisa melhorar
Ver resposta

✓ Resposta: Os dois — eles medem coisas diferentes. O Lighthouse produz dados de laboratório: uma única execução, na sua máquina, com a sua conexão, num ambiente controlado e simulado. O PageSpeed mostra também dados de campo, coletados de visitantes reais do Chrome ao longo de 28 dias — celulares modestos, 4G instável, com outras abas abertas e extensões rodando. O 98 local diz que a página é rápida naquelas condições, e as condições de quem usa são outras. Há um detalhe que explica boa parte da diferença no INP: o Lighthouse praticamente não interage com a página, então o INP de laboratório é estimado; o de campo mede cliques de verdade, em telas de verdade. E o CLS zerado localmente costuma virar 0,18 no campo porque, na sua máquina, imagem e fonte vêm do cache e chegam instantaneamente, sem deslocar nada. A decisão que segue daí é prática: campo manda, laboratório orienta. Use os dados de campo para saber se há problema e para quem, e o laboratório para descobrir onde ele está, porque só ele dá o rastro detalhado. E, sempre que testar localmente, ligue o throttling de CPU e de rede — sem isso, você está medindo o seu computador, não o do usuário.

Exercício 2

A listagem de pedidos demora 8 segundos com 500 registros. O índice já foi criado. O que está acontecendo?

const pedidos = await Pedido.find({ usuario: id }).limit(500).lean();

// para cada pedido, busca o cliente e os itens
for (const pedido of pedidos) {
  pedido.cliente = await Cliente.findById(pedido.clienteId).lean();
  pedido.itens = await Item.find({ pedidoId: pedido._id }).lean();
}
Ver resposta

✓ Resposta: É o problema N+1: uma consulta para trazer a lista e mais duas para cada item dela. Com 500 pedidos são 1001 idas ao banco, e o custo não está no trabalho do banco — cada consulta é rápida — e sim na latência acumulada: 1000 viagens de ida e volta a 8 milissegundos dão exatamente os 8 segundos observados. Nenhum índice resolve isso, porque o gargalo não é a busca, é a quantidade de chamadas. Pior: o await dentro do for as executa uma após a outra. Há três saídas, da pior para a melhor. Paralelizar com Promise.all derruba o tempo para o da consulta mais lenta, mas dispara mil consultas simultâneas e costuma esgotar o pool de conexões. Buscar em lote é bem melhor: colete todos os clienteId e faça uma consulta com $in, depois monte o resultado em memória — três consultas no total, independentemente do número de pedidos. E, no Mongoose, o populate faz exatamente esse agrupamento por você. A lição que fica: desconfie de todo await dentro de laço — é a assinatura visual do N+1, e ele é provavelmente o problema de desempenho mais comum em aplicação com banco de dados.

Exercício 3

O time envolveu tudo em useMemo e React.memo "por garantia". A aplicação ficou mais lenta. Como isso é possível?

const total = useMemo(() => preco * quantidade, [preco, quantidade]);
const nomeCompleto = useMemo(() => `${nome} ${sobrenome}`, [nome, sobrenome]);
const ativo = useMemo(() => status === 'ativo', [status]);
Ver resposta

✓ Resposta: Porque memorizar não é de graça. Cada useMemo guarda o array de dependências, compara item a item na renderização seguinte e mantém o valor anterior vivo na memória. Para uma multiplicação, uma concatenação curta ou uma comparação, esse trabalho de controle custa mais do que refazer a conta — o processador faz essas operações em nanossegundos. O resultado é mais alocação, mais pressão no coletor de lixo e código mais difícil de ler, em troca de nada. useMemo se justifica em duas situações concretas: quando o cálculo é realmente caro, como filtrar e ordenar uma lista de milhares de itens, e quando o valor é um objeto ou array passado a um componente memorizado, caso em que o que importa não é o custo do cálculo e sim manter a identidade estável. Fora disso, é ruído. Vale acrescentar que o React vem caminhando para tornar essa decisão desnecessária: o compilador introduzido nas versões recentes memoriza automaticamente o que precisa, e a orientação da própria equipe é não otimizar manualmente antes de medir. O que sempre compensa é outra coisa: reduzir o que precisa ser renderizado — paginar, virtualizar listas longas, dividir o componente grande em pedaços menores.

Exercício 4

A mesma consulta roda em 12 ms numa coleção de 5 mil documentos e em 4 segundos com 2 milhões. O código não mudou. Como descobrir a causa sem adivinhar?

const pedidos = await Pedido
  .find({ status: 'pendente', criadoEm: { $gte: inicioDoMes } })
  .sort({ criadoEm: -1 })
  .limit(20);
Ver resposta

✓ Resposta: Com .explain('executionStats'), que mostra o plano que o banco escolheu. Os dois campos decisivos são totalDocsExamined e nReturned: se ele examinou 2 milhões para devolver 20, está varrendo a coleção inteira — o estágio aparece como COLLSCAN. Com índice adequado, seria IXSCAN e os dois números ficariam próximos. O comportamento engana justamente porque varredura completa é rápida enquanto a coleção é pequena: com 5 mil documentos tudo cabe na memória e ninguém percebe; o custo cresce linearmente e só vira problema em produção, meses depois. O índice certo aqui é composto, e a ordem dos campos importa: igualdade primeiro, depois ordenação, depois intervalo — { status: 1, criadoEm: -1 } atende ao filtro por status, à ordenação e ao intervalo de data com uma estrutura só. Índices separados em status e em criadoEm ajudariam bem menos, porque o MongoDB usa um índice por consulta na maioria dos casos. Duas ressalvas: índice não é de graça — ele ocupa espaço e torna cada escrita mais lenta, então criar um para cada campo é um erro na direção oposta; e o limit não protege de nada quando há sort sem índice, porque o banco precisa ordenar tudo antes de saber quais são os vinte primeiros.

Exercício 5

A página tem uma imagem grande no topo. O LCP está em 4,2 segundos. Quais destas mudanças ajudam — e qual delas piora?

<!-- A -->
<img src="banner.jpg" loading="lazy" />

<!-- B -->
<img src="banner.webp" width="1200" height="600" fetchpriority="high" />

<!-- C -->
<link rel="preload" as="image" href="banner.webp" />
Ver resposta

✓ Resposta: A piora; B e C ajudam. O loading="lazy" é excelente para imagens abaixo da dobra, mas aplicado ao elemento que define o LCP ele faz o contrário do pretendido: o navegador adia o download até saber que a imagem entrará na tela, e esse adiamento entra inteiro na métrica. É um erro comum, nascido de aplicar lazy em todas as imagens de uma vez. O B acerta em três frentes: o formato moderno reduz o peso pela metade ou mais; fetchpriority="high" diz ao navegador para baixá-la antes dos outros recursos; e width com height reservam o espaço no layout, o que evita o salto de conteúdo e melhora o CLS de quebra. O C antecipa a descoberta do arquivo, útil principalmente quando a imagem é referenciada por CSS ou inserida por JavaScript, casos em que o navegador só a descobre tarde. Vale lembrar que, numa SPA, o LCP costuma ser limitado por outra coisa antes da imagem: o HTML inicial é quase vazio, e nada é pintado até o JavaScript baixar, executar e renderizar — por isso reduzir o bundle da rota inicial, ou adotar renderização no servidor, costuma render mais que qualquer ajuste de imagem.

Comentários

Mais em Javascript

Projeto: API REST com autenticação JWT
Projeto: API REST com autenticação JWT

O Módulo 4 fecha juntando Node, Express, MongoDB e Mongoose numa API de…

Criando e Removendo Elementos Dinamicamente
Criando e Removendo Elementos Dinamicamente

Boa parte de uma interface não está no HTML quando a página carrega — quem a…

Escopo, Hoisting e Closures
Escopo, Hoisting e Closures

Um laço com var e um setTimeout dentro imprime 4, 4, 4 — e explicar esse…