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
- Google — Core Web Vitals: https://web.dev/vitals
- web-vitals library: https://github.com/GoogleChrome/web-vitals
- PageSpeed Insights: https://pagespeed.web.dev
- WebPageTest: https://www.webpagetest.org
- TanStack Virtual: https://tanstack.com/virtual/latest
- rollup-plugin-visualizer: https://github.com/btd/rollup-plugin-visualizer
- MongoDB — Query Optimization: https://www.mongodb.com/docs/manual/core/query-optimization
- MongoDB — Indexes: https://www.mongodb.com/docs/manual/indexes
- node-cache: https://github.com/node-cache/node-cache
- compression (npm): https://github.com/expressjs/compression
- High Performance Browser Networking — Ilya Grigorik (O'Reilly, gratuito): https://hpbn.co
- Web Performance in Action — Jeremy Wagner (Manning)
- roadmap.sh — Frontend Performance: https://roadmap.sh/best-practices/frontend-performance
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.