Todo mundo já comemorou um 95+ no Lighthouse. E todo mundo já abriu o site em produção… e sentiu ele lento.
Esse é o ponto: Lighthouse não representa o mundo real. Usuários reais têm:
- CPU limitada
- rede instável
- múltiplas abas abertas
Se você está otimizando só para laboratório, você está resolvendo o problema errado.
Web Vitals: o que realmente mede a experiência
Se você quer jogar no nível sênior, precisa sair do “achismo”.
Precisa medir.
LCP (Largest Contentful Paint)
Tempo até o principal conteúdo aparecer.
Problema clássico: imagens pesadas ou bloqueio de render.
CLS (Cumulative Layout Shift)
A tela “pulando” enquanto carrega.
Geralmente causado por imagens sem dimensão definida.
INP (Interaction to Next Paint)
Tempo de resposta a interações.
👉 Esse é o que mais denuncia apps React mal otimizados.
Medindo Web Vitals na prática
Aqui vai um exemplo real de coleta de métricas no app:
// web-vitals.ts
import { onLCP, onCLS, onINP } from 'web-vitals';
function sendToAnalytics(metric: any) {
console.log('[Web Vitals]', metric);
// Exemplo: enviar para backend ou ferramenta de analytics
fetch('/analytics', {
method: 'POST',
body: JSON.stringify(metric),
});
}
export function reportWebVitals() {
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
}
// main.tsx ou index.tsx
import { reportWebVitals } from './web-vitals';
reportWebVitals();
Agora você não depende mais de ferramenta externa.
Você mede no mundo real.
Code Splitting: carregando só o necessário
Um dos maiores erros em apps grandes:
👉 carregar tudo upfront.
Anti-pattern real
// App.tsx
import Dashboard from './pages/Dashboard';
import Reports from './pages/Reports';
import Settings from './pages/Settings';
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Router>
);
}
Aqui você já carregou TODAS as páginas no primeiro load.
Code splitting por rota (correto)
// App.tsx
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
const Settings = lazy(() => import('./pages/Settings'));
export default function App() {
return (
<Router>
<Suspense fallback={<div>Carregando...</div>}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</Router>
);
}
O detalhe que pouca gente fala
Se você exagerar no splitting:
- cria dezenas de requests
- gera waterfall
- piora o tempo total
👉 Sênior não divide tudo. Divide com estratégia.
Streaming SSR: percepção > carregamento
Frameworks como Next.js mudaram o jogo.
A ideia é simples:
👉 não espere tudo ficar pronto.
Exemplo com React Server Components + Suspense
// app/page.tsx (Next.js)
import { Suspense } from 'react';
import UserProfile from './UserProfile';
import Posts from './Posts';
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Carregando perfil...</p>}>
<UserProfile />
</Suspense>
<Suspense fallback={<p>Carregando posts...</p>}>
<Posts />
</Suspense>
</div>
);
}
O que acontece aqui
- o HTML começa a ser enviado antes de tudo estar pronto
- partes da UI aparecem aos poucos
- usuário percebe velocidade maior
👉 Isso melhora LCP sem necessariamente reduzir tempo total
React: onde a performance realmente quebra
Grande parte dos gargalos está na renderização.
No React.Re-render desnecessário (caso real)
// Lista grande sem otimização
function ProductList({ products }) {
const [cart, setCart] = useState([]);
function addToCart(product) {
setCart([...cart, product]);
}
return (
<div>
{products.map(product => (
<ProductItem
key={product.id}
product={product}
onAdd={addToCart}
/>
))}
</div>
);
}
Problema:
👉 toda vez que o carrinho muda, TODOS os itens re-renderizam
Otimizando com memo + useCallback
const ProductItem = React.memo(function ProductItem({ product, onAdd }) {
console.log('Render item:', product.name);
return (
<div>
<span>{product.name}</span>
<button onClick={() => onAdd(product)}>Adicionar</button>
</div>
);
});
function ProductList({ products }) {
const [cart, setCart] = useState([]);
const addToCart = useCallback((product) => {
setCart(prev => [...prev, product]);
}, []);
return (
<div>
{products.map(product => (
<ProductItem
key={product.id}
product={product}
onAdd={addToCart}
/>
))}
</div>
);
}
Agora:
- re-render reduz drasticamente
- lista escala melhor
Virtualização: essencial para listas grandes
Sem virtualização:
👉 você renderiza tudo
👉 mesmo o que não está visível
Exemplo com react-window
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style, data }) => {
const item = data[index];
return (
<div style={style}>
{item.name}
</div>
);
};
export default function VirtualizedList({ items }) {
return (
<List
height={400}
itemCount={items.length}
itemSize={50}
width={300}
itemData={items}
>
{Row}
</List>
);
}
Resultado prático
- 10.000 itens → renderiza ~10-20
- ganho absurdo de performance
- scroll suave
Batching e controle de render
React 18 já faz batching automático, mas entender isso evita bugs.
Exemplo real de problema
function Example() {
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(false);
function handleClick() {
setLoading(true);
setTimeout(() => {
setCount(c => c + 1);
setLoading(false);
}, 1000);
}
return (
<button onClick={handleClick}>
{loading ? 'Carregando...' : count}
</button>
);
}
Dependendo do contexto, isso pode gerar múltiplos renders.
Abordagem mais controlada
import { startTransition } from 'react';
function handleClick() {
setLoading(true);
setTimeout(() => {
startTransition(() => {
setCount(c => c + 1);
setLoading(false);
});
}, 1000);
}
Agora você controla prioridade de render.
Benchmark: o diferencial de quem é sênior
Aqui é onde você separa discurso de resultado.
Exemplo real de melhoria
Depois de aplicar:
- code splitting por rota
- memo em lista crítica
- virtualização
Resultado:
- Bundle: 1.3MB → 620KB
- LCP: 4.5s → 2.2s
- INP: 300ms → 110ms
O erro clássico: otimizar sem medir
Você coloca:
- memo em tudo
- lazy em tudo
- abstrações complexas
Sem saber se precisa.
Resultado
- código mais difícil
- bugs mais difíceis
- ganho irrelevante
Conclusão: performance é percepção
Usuário não vê score.
Usuário sente:
- demora
- travamento
- lag
Se você quer performance de verdade:
- meça no mundo real
- otimize gargalos reais
- valide com dados
Porque no fim…
👉 performance boa é invisível
👉 performance ruim é inesquecível




