
Técnicas de Otimização de Performance Web que Todo Frontend Deveria Conhecer
Um cliente uma vez me perguntou por que a página de produto dele levava 6 segundos para ficar interativa em um celular intermediário. Abri o bundle analyzer e encontrei uma biblioteca de date-picker, um conjunto de ícones inteiro e uma biblioteca de gráficos — tudo carregado em uma página que não mostrava nada disso até o usuário clicar em "Adicionar ao carrinho". Isso não é exceção. É a maioria das aplicações React que já auditei.
Trabalho de performance não é sobre micro-otimizar loops de render. Noventa por cento das vezes, é sobre enviar menos JavaScript e carregar ele no momento certo.
Para onde os milissegundos realmente vão
Antes de mexer em código, entenda o que você está otimizando. Duas métricas importam mais para o usuário:
- Time to Interactive (TTI) — quando a página responde a cliques e toques, não só quando parece pronta
- Largest Contentful Paint (LCP) — quando o maior elemento visível (geralmente uma imagem de destaque ou um título) termina de renderizar
As duas são destruídas pela mesma causa raiz: JavaScript demais sendo interpretado e executado antes do navegador conseguir fazer qualquer coisa útil. Um bundle de 500KB não demora só mais para baixar — ele demora mais para ser interpretado e executado, e isso trava a main thread em dispositivos fracos muito mais do que trava no seu notebook com chip M-series.
Teste com CPU limitada (4x de desaceleração no Chrome DevTools) e uma conexão 3G limitada. Sua máquina rápida mente sobre o que o usuário real experimenta.
As três alavancas que realmente fazem diferença
1. Code splitting
Você não precisa da aplicação inteira em um bundle só. Divida por rota automaticamente, e divida por feature manualmente para qualquer coisa pesada e condicional.
// ❌ Carregado em toda página, mesmo que o modal nunca abra
import { ExportModal } from '@/components/ExportModal'
function Dashboard() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Exportar</button>
{open && <ExportModal onClose={() => setOpen(false)} />}
</>
)
}
// ✅ Só é buscado quando o usuário realmente clica em Exportar
import dynamic from 'next/dynamic'
const ExportModal = dynamic(() => import('@/components/ExportModal'), {
loading: () => <Spinner />,
})
function Dashboard() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Exportar</button>
{open && <ExportModal onClose={() => setOpen(false)} />}
</>
)
}
Essa única mudança tirou as dependências do ExportModal — uma biblioteca de PDF, nesse caso — do bundle inicial completamente. No projeto do cliente que mencionei, só isso já cortou o payload inicial de JS em 40%.
2. Entrega de imagens
Imagens costumam ser os bytes mais pesados da página, e são o que mais fácil se erra.
// ❌ Imagem em resolução completa, sem lazy loading, sem dimensões explícitas
<img src="/hero-banner.jpg" alt="Destaque do produto" />
// ✅ Responsiva, lazy por padrão abaixo da dobra, tamanho explícito evita layout shift
import Image from 'next/image'
<Image
src="/hero-banner.jpg"
alt="Destaque do produto"
width={1200}
height={600}
priority // só para imagens acima da dobra
/>
Use priority só na imagem que determina seu LCP — geralmente a imagem de destaque. Marcar tudo como priority anula o propósito; você volta a carregar tudo de forma antecipada.
3. Scripts de terceiros
Analytics, widgets de chat, ferramentas de A/B testing — esses são muitas vezes o verdadeiro culpado, não o seu próprio código. Cada um adiciona sua própria requisição de rede, seu próprio custo de parsing de JS, e às vezes seu próprio comportamento de bloqueio de render.
// ❌ Bloqueia o parsing imediatamente, roda antes da página ficar interativa
<script src="https://widget.example.com/chat.js"></script>
// ✅ Carrega depois que a página já está interativa, não disputa a main thread no primeiro load
import Script from 'next/script'
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
A prop strategy do next/script te dá um controle que a maioria dos times não sabe que existe: beforeInteractive, afterInteractive ou lazyOnload. Por padrão, deixe scripts de terceiros em lazyOnload, a menos que algo dependa deles estarem prontos imediatamente.
Medindo o impacto real
Não chute. Rode o Lighthouse ou o next build com o bundle analyzer antes e depois:
ANALYZE=true npm run build
Na auditoria que mencionei antes, os números ficaram assim:
| Métrica | Antes | Depois |
|---|---|---|
| Bundle JS inicial | 780 KB | 410 KB |
| Time to Interactive (3G, celular intermediário) | 6.1s | 2.8s |
| Largest Contentful Paint | 3.4s | 1.6s |
Mesmas funcionalidades. Mesmo design. A única mudança foi adiar o que não precisava carregar imediatamente.
Erros comuns
- ❌ Otimizar
useMemo/useCallbackantes de checar o tamanho do bundle. Performance de re-render raramente importa se a página já demorou 4 segundos para ficar interativa. Resolva o problema maior primeiro. - ❌ Fazer lazy-load de tudo, incluindo conteúdo acima da dobra. Se você importa dinamicamente sua seção de destaque, você atrasa exatamente o que determina seu score de LCP. Faça lazy load do que está escondido, não do que já é visível de cara.
- ❌ Importar uma biblioteca inteira para uma função só.
import _ from 'lodash'traz a biblioteca inteira mesmo que você só usedebounce. Useimport debounce from 'lodash/debounce'ou uma implementação nativa. - ❌ Ignorar o impacto de script de terceiro porque "não é meu código". O usuário não liga de quem é o código que deixou a página lenta. Audite todo script tag do mesmo jeito que você audita seu próprio bundle.
Boas práticas
- Defina um orçamento de tamanho de bundle e reforce ele na CI. Ferramentas como
bundlesizeou os avisos nativos do Next.js pegam regressões antes de irem pro ar, não depois de um usuário reclamar. - Use
next/dynamicpor padrão para qualquer coisa abaixo da dobra ou atrás de uma interação — modais, abas que não estão ativas no momento, gráficos, editores de texto rico. - Comprima e sirva formatos de imagem modernos. WebP ou AVIF em vez de JPEG/PNG reduz bastante o peso da imagem sem perda de qualidade visível na maioria dos casos.
- Faça preconnect para origens de terceiros que você não consegue evitar.
<link rel="preconnect" href="https://fonts.googleapis.com">tira o handshake de DNS/TLS do caminho crítico. - Meça de novo depois de toda dependência "pequena" que você adicionar. Uma única biblioteca de date-picker pode adicionar 80KB. Confira antes de dar merge, não depois da aplicação já estar lenta.
O que fazer agora
Roda um bundle analyzer na sua aplicação hoje — não na próxima sprint. Encontre os três maiores chunks que não são a sua UI principal, e pergunte se eles precisam carregar no primeiro paint. Na maioria dos casos, a resposta é não, e mover eles para trás de um dynamic() ou lazyOnload leva quinze minutos por componente.
Faça isso com consistência, e o tamanho do bundle para de ser um projeto trimestral de faxina e vira um hábito que pega regressões antes delas chegarem em produção.