- Fundamentos: O que é Performance e por que Importa
- Core Web Vitals: LCP, INP e CLS na Prática
- Entrega & Rendering: CDN, Lazy Loading e Estratégias de Render
- JavaScript: Seu Bundle Está Pesado Demais
- CSS: Critical Path e Layout Thrashing
- DevTools: Explorando a Performance no Navegador
- Monitoramento: Medindo o que Importa em Produção
JavaScript é a categoria de recurso mais cara do frontend — não só em bytes, mas em tempo de processamento. Um arquivo de 300KB de imagem e um de 300KB de JS não são equivalentes: a imagem é apenas decodificada, enquanto o JS é baixado, parseado, compilado e executado.
"JavaScript é o recurso mais caro que você pode enviar para dispositivos móveis." — Addy Osmani
O Custo Real do JS
Limitado pela rede. Em 4G, 300KB gzipped ~1s. Em 3G, pode ser 3-5s.
O browser lê o texto e constrói a AST. Linear com o tamanho do arquivo.
Converte AST em bytecode. Em dispositivos de entrada, pode ser 5-10x mais lento que em MacBook Pro.
Roda o código. Long tasks bloqueiam interações do usuário durante a execução.
Um bundle de 100KB gzipped pode ser 300-400KB após descompressão. Em dispositivos lentos, parsear e compilar esses 300-400KB pode levar 2-3 segundos, bloqueando qualquer interação. Mantenha o JS crítico (para a rota atual) abaixo de 100-150KB gzipped.
Calculadora de Custo de JS
Antes de otimizar, entenda o impacto real do seu bundle em diferentes dispositivos. Arraste o slider para ver o custo estimado de parse + compile:
Calculadora de custo de JavaScript
Descomprimido: ~750 KB — parse + compile ocorre sobre o tamanho descomprimido
⚠️ Atenção — usuários em dispositivos lentos sentem isso
❌ Crítico — bloqueia o thread por mais de 600ms
❌ Crítico — bloqueia o thread por mais de 600ms
Estimativas baseadas nos dados de pesquisa do Chrome team (Addy Osmani, 2019–2023)
Analisando seu Bundle
Antes de otimizar, você precisa ver o que tem no bundle.
# Next.js
npm install @next/bundle-analyzer// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});ANALYZE=true npm run buildAbre um treemap interativo mostrando cada módulo e seu tamanho. Os vilões mais comuns: moment.js (70KB), lodash completo, @mui/icons-material, bibliotecas duplicadas.
Antes de instalar qualquer dependência, cheque o custo em bundlephobia.com. Ele mostra o tamanho gzipped, o tamanho descomprimido e se a biblioteca suporta tree shaking.
Substituições comuns que valem a pena:
- moment (~70KB) → date-fns (~5KB)
Economia de ~65KB. date-fns tem tree shaking completo — você paga só pelas funções que importa.
- lodash (~70KB) → funções nativas ou es-toolkit
A maioria das funções do lodash tem equivalente nativo em ES2022+. Para o que não tem, es-toolkit é tree-shakeable.
- axios (~15KB) → fetch nativo
fetch é nativo no browser e no Node 18+. Interceptors e cancelamento são fáceis de implementar manualmente ou via ky (~3KB).
- uuid (~8KB) → crypto.randomUUID()
crypto.randomUUID() é nativo em todos os browsers modernos e no Node 14.17+. Zero dependência.
Tree Shaking
Tree shaking elimina código não utilizado durante o build. Para funcionar, a biblioteca precisa usar ESM (import/export), não CommonJS (require).
// ❌ Importa o lodash inteiro — ~70KB gzipped
import _ from 'lodash';
const result = _.groupBy(items, 'category');
// ✅ Só o que usa — ~1KB
import groupBy from 'lodash/groupBy';
// ✅ date-fns tem tree shaking completo
import { format, parseISO } from 'date-fns';Algumas bibliotecas têm tree shaking quebrado apesar de usar ESM. O bundle analyzer revela isso — se você importa 2 funções e vê a biblioteca inteira no treemap, tree shaking não está funcionando. Verifique se a biblioteca tem sideEffects: false no package.json.
Code Splitting
Code splitting divide o bundle em chunks menores, carregando apenas o necessário para a rota atual.
Dynamic imports para bibliotecas pesadas
// ❌ Importa o chart.js para todos os usuários
import Chart from 'chart.js';
// ✅ Só baixa quando o usuário precisar
async function showChart() {
const { Chart } = await import('chart.js');
new Chart(ctx, config);
}Estratégia de chunking no Vite
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'], // cache independente do app
charts: ['recharts', 'd3'],
forms: ['react-hook-form', 'zod'],
},
},
},
},
};Chunks vendor com immutable no cache ficam cacheados mesmo quando o código do app muda — o usuário não baixa novamente React e React DOM a cada deploy.
Rendering e Critical Rendering Path
O browser precisa construir DOM + CSSOM antes de renderizar qualquer pixel. Cada recurso que bloqueia esse processo atrasa o FCP e o LCP.
Mas bloqueia renderização. CSS externo no head deve ser crítico ou carregado assincronamente via preload trick
Scripts síncronos param o parsing do HTML. Use defer (mantém ordem) ou async (executa ao carregar, sem garantia de ordem)
Ler e escrever DOM no mesmo frame força reflows repetidos. Agrupe leituras antes das escritas usando requestAnimationFrame
Tasks longas (>50ms) bloqueiam interação. Quebre em chunks com scheduler.yield() ou use Web Workers para processamento pesado
defer vs async vs type="module"
- defer — download paralelo, executa após parse
Mantém a ordem de execução entre scripts. Use para scripts que dependem do DOM ou uns dos outros.
- async — download paralelo, executa ao carregar
Sem garantia de ordem. Use para scripts completamente independentes: analytics, pixels de ad, chat widgets.
- type="module" — download paralelo, executa após parse
Comporta-se como
deferpor padrão. Use para ES Modules modernos. Também habilita top-levelawait.
Para a grande maioria dos scripts de aplicação: use defer. Para scripts de analytics e ads que não dependem do DOM nem de outros scripts: async. Para módulos ES modernos: type="module" (que já tem comportamento de defer por padrão).
Nunca coloque um script síncrono no head sem uma razão muito específica — bloqueia tudo.
Otimizações de Runtime
import { useTransition, useState } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
setQuery(e.target.value); // urgente — atualiza input imediatamente
startTransition(() => {
setResults(filterResults(e.target.value)); // pode ser interrompido
});
}
return (
<>
<input value={query} onChange={handleSearch} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
);
}O React pode interromper o update de results se o usuário continuar digitando. Impacto direto no INP.
// worker.js
self.onmessage = ({ data }) => {
const result = heavyComputation(data);
self.postMessage(result);
};
// App.js — move processamento pesado para thread separada
const worker = new Worker('/worker.js');
worker.postMessage(largeDataset);
worker.onmessage = ({ data }) => setResult(data);Qualquer operação que levaria mais de 50ms no main thread é candidata: processamento de dados, parsing, criptografia, compressão de imagens.
// ❌ Nova referência a cada render — filho sempre re-renderiza
function Parent() {
const handleClick = () => console.log('click');
return <ExpensiveChild onClick={handleClick} />;
}
// ✅ Referência estável
function Parent() {
const handleClick = useCallback(() => console.log('click'), []);
return <ExpensiveChild onClick={handleClick} />;
}
// ✅ Memoriza resultado de cálculo pesado
const result = useMemo(() => heavyComputation(data), [data]);Use useCallback e useMemo quando o custo da recomputação ou do re-render do filho for maior que o overhead da memoização — não por padrão em tudo.
No próximo artigo, tratamos de CSS performance — critical path, layout thrashing, animações eficientes e como o browser pinta a tela.
As minhas notas
Selecione um trecho e use Destacar no menu (somente neste aparelho).