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

Padrões de Projeto em JavaScript
Padrões de Projeto em JavaScript

Dizer "isto é um Adapter" numa revisão economiza um parágrafo de explicação, e…

Dominando o JavaScript
Dominando o JavaScript

Cinquenta e dois artigos em seis módulos, do primeiro console.log ao dashboard…

Express.js: o framework web do Node
Express.js: o framework web do Node

O mesmo endpoint que ocupava trinta linhas de if e regex cabe em quatro com…