Core Web Vitals em 2026: O Que Realmente Faz Diferença
Esqueça a teoria — aqui está o que eu realmente fiz para ter LCP abaixo de 2.5s, CLS zero e INP abaixo de 200ms em um site Next.js de produção real. Técnicas específicas, não conselhos vagos.
Passei quase duas semanas tornando este site rápido. Não "parece rápido num audit do Lighthouse no meu MacBook M3" rápido. Realmente rápido. Rápido num celular Android de R$ 800 numa conexão 4G instável dentro do metrô. Rápido onde importa.
O resultado: LCP abaixo de 1.8s, CLS em 0.00, INP abaixo de 120ms. Todos os três verdes nos dados do CrUX, não apenas scores de laboratório. E aprendi algo no processo — a maioria dos conselhos de performance na internet são desatualizados, vagos, ou ambos.
"Otimize suas imagens" não é conselho. "Use lazy loading" sem contexto é perigoso. "Minimize o JavaScript" é óbvio mas não te diz nada sobre o quê cortar.
Aqui está o que eu realmente fiz, na ordem que fez diferença.
Por Que Core Web Vitals Ainda Importam em 2026#
Vou ser direto: o Google usa Core Web Vitals como sinal de ranking. Não o único sinal, e nem o mais importante. Relevância do conteúdo, backlinks e autoridade do domínio ainda dominam. Mas nas margens — onde duas páginas têm conteúdo e autoridade comparáveis — performance é o desempate. E na internet, milhões de páginas vivem nessas margens.
Mas esqueça o SEO por um segundo. A razão real para se importar com performance são os usuários. Os dados não mudaram muito nos últimos cinco anos:
- 53% das visitas mobile são abandonadas se a página demora mais de 3 segundos para carregar (pesquisa Google/SOASTA, ainda válida)
- Cada 100ms de latência custa aproximadamente 1% em conversões (descoberta original da Amazon, validada repetidamente)
- Usuários que experimentam layout shifts são significativamente menos propensos a concluir uma compra ou preencher um formulário
Core Web Vitals em 2026 consistem em três métricas:
| Métrica | O Que Mede | Bom | Precisa Melhorar | Ruim |
|---|---|---|---|---|
| LCP | Performance de carregamento | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | Estabilidade visual | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | Responsividade | ≤ 200ms | 200ms – 500ms | > 500ms |
Esses limiares não mudaram desde que o INP substituiu o FID em março de 2024. Mas as técnicas para atingi-los evoluíram, especialmente no ecossistema React/Next.js.
LCP: A Que Mais Importa#
Largest Contentful Paint mede quando o maior elemento visível na viewport termina de renderizar. Para a maioria das páginas, isso é uma imagem hero, um heading ou um grande bloco de texto.
Passo 1: Encontre Seu Elemento LCP Real#
Antes de otimizar qualquer coisa, você precisa saber qual é o seu elemento LCP. As pessoas assumem que é a imagem hero. Às vezes é uma web font renderizando o <h1>. Às vezes é uma imagem de background aplicada via CSS. Às vezes é o poster frame de um <video>.
Abra o Chrome DevTools, vá no painel Performance, grave um carregamento de página e procure o marcador "LCP". Ele te diz exatamente qual elemento disparou o LCP.
Você também pode usar a biblioteca web-vitals para logar programaticamente:
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});Neste site, o elemento LCP acabou sendo a imagem hero na homepage e o primeiro parágrafo de texto nos posts do blog. Dois elementos diferentes, duas estratégias de otimização diferentes.
Passo 2: Preload do Recurso LCP#
Se o seu elemento LCP é uma imagem, a coisa mais impactante que você pode fazer é preload. Por padrão, o navegador descobre imagens quando analisa o HTML, o que significa que a requisição da imagem não começa até que o HTML seja baixado, analisado e a tag <img> seja alcançada. O preload move essa descoberta para o início.
No Next.js, você pode adicionar um link de preload no seu layout ou página:
import Head from "next/head";
export default function HeroSection() {
return (
<>
<Head>
<link
rel="preload"
as="image"
href="/images/hero-optimized.webp"
type="image/webp"
fetchPriority="high"
/>
</Head>
<section className="relative h-[600px]">
<img
src="/images/hero-optimized.webp"
alt="Hero banner"
width={1200}
height={600}
fetchPriority="high"
decoding="sync"
/>
</section>
</>
);
}Repare no fetchPriority="high". Essa é a API Fetch Priority mais recente, e é um divisor de águas. Sem ela, o navegador usa suas próprias heurísticas para priorizar recursos — e essas heurísticas frequentemente erram, especialmente quando você tem múltiplas imagens above the fold.
Neste site, adicionar fetchPriority="high" na imagem LCP reduziu o LCP em ~400ms. Essa é a maior vitória que já obtive com uma mudança de uma linha.
Passo 3: Elimine Recursos que Bloqueiam a Renderização#
CSS bloqueia a renderização. Todo ele. Se você tem um stylesheet de 200KB carregado via <link rel="stylesheet">, o navegador não vai pintar nada até que esteja totalmente baixado e analisado.
A solução tem três partes:
-
Inline do CSS crítico — Extraia o CSS necessário para o conteúdo above-the-fold e faça inline em uma tag
<style>no<head>. O Next.js faz isso automaticamente quando você usa CSS Modules ou Tailwind com purge adequado. -
Defer do CSS não-crítico — Se você tem stylesheets para conteúdo below-the-fold (uma biblioteca de animação do footer, um componente de gráfico), carregue-os assincronamente:
<link
rel="preload"
href="/styles/charts.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/styles/charts.css" />
</noscript>- Remova CSS não utilizado — Tailwind CSS v4 faz isso automaticamente com seu engine JIT. Mas se você está importando bibliotecas CSS de terceiros, audite-as. Encontrei uma biblioteca de componentes importando 180KB de CSS para um único componente de tooltip. Substituí por um componente customizado de 20 linhas e economizei 170KB.
Passo 4: Tempo de Resposta do Servidor (TTFB)#
O LCP não pode ser rápido se o TTFB é lento. Se o seu servidor demora 800ms para responder, seu LCP vai ser no mínimo 800ms + todo o resto.
Neste site (Node.js + PM2 + Nginx em um VPS), medi o TTFB em torno de 180ms em um cold hit. Aqui está o que fiz para manter assim:
- ISR (Incremental Static Regeneration) para posts do blog — as páginas são pré-renderizadas no build time e revalidadas periodicamente. A primeira visita serve um arquivo estático diretamente do cache do reverse proxy do Nginx.
- Headers de cache na edge —
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400em páginas estáticas. - Compressão Gzip/Brotli no Nginx — reduz o tamanho de transferência em 60-80%.
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;Meu antes/depois no LCP:
- Antes da otimização: 3.8s (percentil 75, CrUX)
- Depois de preload + fetchPriority + compressão: 1.8s
- Melhoria total: redução de 53%
CLS: Morte por Mil Deslocamentos#
Cumulative Layout Shift mede quanto o conteúdo visível se move durante o carregamento da página. Um CLS de 0 significa que nada se moveu. Um CLS acima de 0.1 significa que algo está visualmente irritando seus usuários.
CLS é a métrica que a maioria dos desenvolvedores subestima. Você não nota na sua máquina de desenvolvimento rápida com tudo em cache. Seus usuários notam nos celulares deles, em conexões lentas, onde fontes carregam tarde e imagens aparecem uma por uma.
Os Culpados de Sempre#
1. Imagens sem dimensões explícitas
Essa é a causa mais comum de CLS. Quando uma imagem carrega, ela empurra o conteúdo abaixo para baixo. A solução é constrangedoramente simples: sempre especifique width e height nas tags <img>.
// RUIM — causa layout shift
<img src="/photo.jpg" alt="Team photo" />
// BOM — navegador reserva espaço antes da imagem carregar
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />Se você está usando o <Image> do Next.js, ele lida com isso automaticamente desde que você forneça dimensões ou use fill com um container pai dimensionado.
Mas aqui está a pegadinha: se você usa o modo fill, o container pai precisa ter dimensões explícitas ou a imagem vai causar CLS:
// RUIM — pai sem dimensões
<div className="relative">
<Image src="/photo.jpg" alt="Team" fill />
</div>
// BOM — pai com aspect ratio explícito
<div className="relative aspect-video w-full">
<Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>2. Web fonts causando FOUT/FOIT
Quando uma fonte customizada carrega, o texto renderizado na fonte fallback é re-renderizado na fonte customizada. Se as duas fontes têm métricas diferentes (quase sempre têm), tudo se desloca.
A solução moderna é font-display: swap combinado com fontes fallback ajustadas em tamanho:
// Usando next/font — a melhor abordagem para Next.js
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// next/font gera automaticamente fontes fallback ajustadas em tamanho
// Isso elimina o CLS da troca de fontes
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}O next/font é genuinamente uma das melhores coisas do Next.js. Ele baixa fontes no build time, faz self-host (sem requisição ao Google Fonts em runtime) e gera fontes fallback ajustadas em tamanho para que a troca da fallback para a customizada cause zero layout shift. Medi o CLS de fontes em 0.00 depois de trocar para next/font. Antes, com um <link> padrão do Google Fonts, era 0.04-0.08.
3. Injeção dinâmica de conteúdo
Anúncios, banners de cookies, barras de notificação — qualquer coisa que é injetada no DOM depois do render inicial causa CLS se empurra conteúdo para baixo.
A solução: reserve espaço para conteúdo dinâmico antes de ele carregar.
// Banner de cookies — reserva espaço na parte inferior
function CookieBanner() {
const [accepted, setAccepted] = useState(false);
if (accepted) return null;
return (
// Posicionamento fixo não causa CLS porque não
// afeta o fluxo do documento
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
<p>We use cookies. You know the drill.</p>
<button onClick={() => setAccepted(true)}>Accept</button>
</div>
);
}Usar position: fixed ou position: absolute para elementos dinâmicos é uma abordagem livre de CLS porque esses elementos não afetam o fluxo normal do documento.
4. O truque CSS do aspect-ratio
Para containers responsivos onde você sabe o aspect ratio mas não as dimensões exatas, use a propriedade CSS aspect-ratio:
// Embed de vídeo sem CLS
function VideoEmbed({ src }: { src: string }) {
return (
<div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
<iframe
src={src}
className="w-full h-full"
title="Embedded video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media"
allowFullScreen
/>
</div>
);
}A utility aspect-video (que é aspect-ratio: 16/9) reserva exatamente a quantidade certa de espaço. Sem deslocamento quando o iframe carrega.
5. Skeleton screens
Para conteúdo que carrega assincronamente (dados de API, componentes dinâmicos), mostre um skeleton que corresponda às dimensões esperadas:
function PostCardSkeleton() {
return (
<div className="animate-pulse rounded-lg border p-4">
<div className="h-48 w-full rounded bg-gray-200" />
<div className="mt-4 space-y-2">
<div className="h-6 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
</div>
);
}
function PostList() {
const { data: posts, isLoading } = usePosts();
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}O ponto chave é que PostCardSkeleton e PostCard devem ter as mesmas dimensões. Se o skeleton tem 200px de altura e o card real tem 280px, você ainda terá um deslocamento.
Meus resultados de CLS:
- Antes: 0.12 (a troca de fonte sozinha era 0.06)
- Depois: 0.00 — literalmente zero, em milhares de carregamentos de página nos dados do CrUX
INP: O Novato Que Morde#
Interaction to Next Paint substituiu o First Input Delay em março de 2024, e é uma métrica fundamentalmente mais difícil de otimizar. O FID só media o atraso antes da primeira interação ser processada. O INP mede toda interação ao longo do ciclo de vida da página e reporta a pior (no percentil 75).
Isso significa que uma página pode ter ótimo FID mas péssimo INP se, digamos, clicar em um menu dropdown 30 segundos após o carregamento dispara um reflow de 500ms.
O Que Causa INP Alto#
- Tarefas longas na main thread — Qualquer execução JavaScript que leva mais de 50ms bloqueia a main thread. Interações de usuário que acontecem durante uma tarefa longa têm que esperar.
- Re-renders caros no React — Uma atualização de estado que causa re-render de 200 componentes leva tempo. O usuário clica em algo, React reconcilia, e o paint não acontece por 300ms.
- Layout thrashing — Ler propriedades de layout (como
offsetHeight) e depois escrever nelas (como mudarstyle.height) em um loop força o navegador a recalcular o layout sincronamente. - DOM grande — Mais nós no DOM significa recálculo de estilo e layout mais lentos. Um DOM com 5.000 nós é perceptivelmente mais lento que um com 500.
Quebrando Tarefas Longas com scheduler.yield()#
A técnica mais impactante para INP é quebrar tarefas longas para que o navegador possa processar interações do usuário entre os pedaços:
async function processLargeDataset(items: DataItem[]) {
const results: ProcessedItem[] = [];
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]));
// A cada 10 itens, devolve o controle ao navegador
// Isso permite que interações pendentes do usuário sejam processadas
if (i % 10 === 0 && "scheduler" in globalThis) {
await scheduler.yield();
}
}
return results;
}scheduler.yield() está disponível no Chrome 129+ (setembro de 2024) e é a forma recomendada de devolver o controle à main thread. Para navegadores que não suportam, você pode usar um fallback com setTimeout(0):
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}useTransition para Atualizações Não-Urgentes#
O React 18+ nos dá useTransition, que diz ao React que certas atualizações de estado não são urgentes e podem ser interrompidas por trabalho mais importante (como responder a input do usuário):
import { useState, useTransition } from "react";
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// Essa atualização é urgente — o input deve refletir a tecla imediatamente
setQuery(value);
// Essa atualização NÃO é urgente — filtrar 10.000 itens pode esperar
startTransition(() => {
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
}
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Search..."
className="w-full rounded border px-4 py-2"
/>
{isPending && (
<p className="mt-2 text-sm text-gray-500">Filtering...</p>
)}
<ul className="mt-4 space-y-2">
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}Sem startTransition, digitar no input de busca seria travado porque o React tentaria filtrar 10.000 itens sincronamente antes de atualizar o DOM. Com startTransition, o input atualiza imediatamente e a filtragem acontece em background.
Medi o INP em uma página de ferramenta que tinha um handler de input complexo. Antes do useTransition: 380ms de INP. Depois: 90ms de INP. Isso é uma melhoria de 76% com uma mudança de API do React.
Debouncing em Handlers de Input#
Para handlers que disparam operações caras (chamadas de API, computação pesada), aplique debounce:
import { useCallback, useRef } from "react";
function useDebounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
((...args: unknown[]) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => fn(...args), delay);
}) as T,
[fn, delay]
);
}
// Uso
function LiveSearch() {
const [results, setResults] = useState<SearchResult[]>([]);
const search = useDebounce(async (query: string) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data.results);
}, 300);
return (
<input
type="text"
onChange={(e) => search(e.target.value)}
placeholder="Search..."
/>
);
}300ms é meu valor padrão de debounce. É curto o suficiente para que os usuários não notem o atraso, longo o suficiente para evitar disparar a cada tecla.
Web Workers para Computação Pesada#
Se você tem computação genuinamente pesada (parsing de JSON grande, manipulação de imagens, cálculos complexos), mova para fora da main thread:
// worker.ts
self.addEventListener("message", (event) => {
const { data, operation } = event.data;
switch (operation) {
case "sort": {
// Isso poderia levar 500ms para datasets grandes
const sorted = data.sort((a: number, b: number) => a - b);
self.postMessage({ result: sorted });
break;
}
case "filter": {
const filtered = data.filter((item: DataItem) =>
complexFilterLogic(item)
);
self.postMessage({ result: filtered });
break;
}
}
});// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
function useWorker() {
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(
new URL("../workers/worker.ts", import.meta.url)
);
return () => workerRef.current?.terminate();
}, []);
const process = useCallback(
(operation: string, data: unknown): Promise<unknown> => {
return new Promise((resolve) => {
if (!workerRef.current) return;
workerRef.current.onmessage = (event) => {
resolve(event.data.result);
};
workerRef.current.postMessage({ operation, data });
});
},
[]
);
return { process };
}Web Workers operam em uma thread separada, então mesmo uma computação de 2 segundos não afeta o INP. A main thread fica livre para lidar com interações do usuário.
Meus resultados de INP:
- Antes: 340ms (a pior interação era uma ferramenta de teste de regex com tratamento de input complexo)
- Depois de useTransition + debouncing: 110ms
- Melhoria: redução de 68%
As Vitórias Específicas do Next.js#
Se você está no Next.js (13+ com App Router), tem acesso a algumas primitivas de performance poderosas que a maioria dos desenvolvedores não explora totalmente.
next/image — Mas Configurado Corretamente#
next/image é ótimo, mas a configuração padrão deixa performance na mesa:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1 ano
},
};
export default nextConfig;Configurações chave:
formats: ["image/avif", "image/webp"]— AVIF é 20-50% menor que WebP. A ordem importa: Next.js tenta AVIF primeiro, faz fallback para WebP, depois para o formato original.minimumCacheTTL— Padrão é 60 segundos. Para um blog, imagens não mudam. Cache por um ano.deviceSizeseimageSizes— Os padrões incluem 3840px. A menos que você esteja servindo imagens 4K, corte essa lista. Cada tamanho gera uma imagem cacheada separada, e tamanhos não utilizados desperdiçam espaço em disco e tempo de build.
E sempre use a prop sizes para dizer ao navegador em qual tamanho a imagem será renderizada:
// Imagem hero full-width
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
sizes="100vw"
priority // Imagem LCP — não faça lazy load!
/>
// Imagem de card em um grid responsivo
<Image
src="/card.jpg"
alt="Card"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>Sem sizes, o navegador pode baixar uma imagem de 1200px para um slot de 300px. Isso é bytes desperdiçados e tempo desperdiçado.
A prop priority na imagem LCP é crítica. Ela desabilita o lazy loading e adiciona fetchPriority="high" automaticamente. Se o seu elemento LCP é um next/image, apenas adicione priority e você já fez a maior parte.
next/font — Fontes com Zero Layout Shift#
Cobri isso na seção de CLS, mas merece ênfase. next/font é a única solução de carregamento de fontes que vi que consistentemente atinge zero CLS:
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${inter.variable} ${jetbrainsMono.variable}`}
>
<body className="font-sans">{children}</body>
</html>
);
}Duas fontes, zero CLS, zero requisições externas em runtime. As fontes são baixadas no build time e servidas do seu próprio domínio.
Streaming com Suspense#
É aqui que o Next.js fica realmente interessante para performance. Com o App Router, você pode fazer streaming de partes da página para o navegador conforme ficam prontas:
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
export default function BlogPage() {
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
{/* Isso carrega rápido — faça streaming imediatamente */}
<h1 className="text-4xl font-bold">Blog</h1>
{/* Isso requer uma query ao banco — faça streaming quando pronto */}
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
<aside>
{/* Sidebar pode carregar independentemente */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
</div>
);
}O navegador recebe o shell (heading, navegação, layout) imediatamente. A lista de posts e a sidebar chegam via streaming conforme seus dados ficam disponíveis. O usuário vê um carregamento inicial rápido, e o conteúdo preenche progressivamente.
Isso é particularmente poderoso para o LCP. Se o seu elemento LCP é o heading (não a lista de posts), ele renderiza imediatamente independente de quanto tempo a query ao banco leva.
Configuração de Segmento de Rota#
O Next.js permite configurar cache e revalidação no nível do segmento de rota:
// app/blog/page.tsx
// Revalida essa página a cada hora
export const revalidate = 3600;
// app/tools/[slug]/page.tsx
// Essas páginas de ferramentas são totalmente estáticas — gera no build time
export const dynamic = "force-static";
// app/api/search/route.ts
// API route — nunca cachear
export const dynamic = "force-dynamic";Neste site, posts do blog usam revalidate = 3600 (1 hora). Páginas de ferramentas usam force-static porque seu conteúdo nunca muda entre deploys. A API de busca usa force-dynamic porque cada requisição é única.
O resultado: a maioria das páginas serve do cache estático, o TTFB fica abaixo de 50ms para páginas cacheadas, e o servidor mal sua.
Ferramentas de Medição: Confie nos Dados, Não nos Seus Olhos#
Sua percepção de performance não é confiável. Sua máquina de desenvolvimento tem 32GB de RAM, um SSD NVMe e uma conexão gigabit. Seus usuários não têm.
O Stack de Medição Que Eu Uso#
1. Chrome DevTools Performance Panel
A ferramenta mais detalhada disponível. Grave um carregamento de página, olhe o flamechart, identifique tarefas longas, encontre recursos que bloqueiam a renderização. É aqui que passo a maior parte do meu tempo de debugging.
Coisas chave para procurar:
- Cantos vermelhos em tarefas = tarefas longas (>50ms)
- Eventos de Layout/Paint disparados por JavaScript
- Blocos grandes de "Evaluate Script" (JavaScript demais)
- Waterfall de rede mostrando recursos descobertos tarde
2. Lighthouse
Bom para uma verificação rápida, mas não otimize para scores do Lighthouse. O Lighthouse roda em um ambiente simulado com throttling que não corresponde perfeitamente às condições do mundo real. Já vi páginas pontuarem 98 no Lighthouse e terem 4s de LCP no campo.
Use o Lighthouse para orientação direcional, não como um placar.
3. PageSpeed Insights
A ferramenta mais importante para sites em produção porque mostra dados reais do CrUX — medições reais de usuários reais do Chrome nos últimos 28 dias. Dados de laboratório te dizem o que poderia acontecer. Dados do CrUX te dizem o que acontece.
4. A Biblioteca web-vitals
Adicione isso ao seu site de produção para coletar métricas de usuários reais:
// components/analytics/WebVitals.tsx
"use client";
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
function sendToAnalytics(metric: Metric) {
// Envia para seu endpoint de analytics
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Usa sendBeacon para não bloquear o unload da página
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", {
body,
method: "POST",
keepalive: true,
});
}
}
export function WebVitals() {
useEffect(() => {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}, []);
return null;
}Isso te dá seus próprios dados tipo CrUX, mas com mais detalhe. Você pode segmentar por página, tipo de dispositivo, velocidade de conexão, região geográfica — o que precisar.
5. Chrome User Experience Report (CrUX)
O dataset BigQuery do CrUX é gratuito e contém dados contínuos de 28 dias para milhões de origens. Se seu site tem tráfego suficiente, você pode consultar seus próprios dados:
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://yoursite.com'
AND yyyymm = 202603A Lista de Eliminação do Waterfall#
Scripts de terceiros são o assassino de performance número um na maioria dos sites. Aqui está o que encontrei e o que fiz sobre isso.
Google Tag Manager (GTM)#
O GTM em si tem ~80KB. Mas o GTM carrega outros scripts — analytics, pixels de marketing, ferramentas de teste A/B. Já vi configurações de GTM que carregam 15 scripts adicionais totalizando 2MB.
Minha abordagem: Não use GTM em produção. Carregue scripts de analytics diretamente, aplique defer em tudo e use loading="lazy" para scripts que podem esperar:
// Em vez do GTM carregando tudo
// Carregue apenas o que você precisa, quando precisa
export function AnalyticsScript() {
return (
<script
defer
src="https://analytics.example.com/script.js"
data-website-id="your-id"
/>
);
}Se você absolutamente precisa usar GTM, carregue-o depois que a página estiver interativa:
"use client";
import { useEffect } from "react";
export function DeferredGTM({ containerId }: { containerId: string }) {
useEffect(() => {
// Espera até depois do carregamento da página para injetar o GTM
const timer = setTimeout(() => {
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
script.async = true;
document.head.appendChild(script);
}, 3000); // 3 segundos de atraso
return () => clearTimeout(timer);
}, [containerId]);
return null;
}Sim, você vai perder dados de usuários que saem nos primeiros 3 segundos. Na minha experiência, esse é um trade-off que vale a pena. Esses usuários não iam converter de qualquer forma.
Widgets de Chat#
Widgets de chat ao vivo (Intercom, Drift, Crisp) são alguns dos piores ofensores. O Intercom sozinho carrega 400KB+ de JavaScript. Em uma página onde 2% dos usuários realmente clicam no botão de chat, são 400KB de JavaScript para 98% dos usuários.
Minha solução: Carregue o widget na interação.
"use client";
import { useState } from "react";
export function ChatButton() {
const [loaded, setLoaded] = useState(false);
function loadChat() {
if (loaded) return;
// Carrega o script do widget de chat apenas quando o usuário clica
const script = document.createElement("script");
script.src = "https://chat-widget.example.com/widget.js";
script.onload = () => {
// Inicializa o widget depois do script carregar
window.ChatWidget?.open();
};
document.head.appendChild(script);
setLoaded(true);
}
return (
<button
onClick={loadChat}
className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
aria-label="Open chat"
>
{loaded ? "Loading..." : "Chat with us"}
</button>
);
}JavaScript Não Utilizado#
Execute Coverage no Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Ele mostra exatamente quanto de cada script é realmente usado na página atual.
Em um site Next.js típico, eu geralmente encontro:
- Bibliotecas de componentes carregadas inteiras — Você importa
Buttonde uma biblioteca de UI, mas a biblioteca inteira é empacotada. Solução: use bibliotecas que fazem tree-shake ou importe de subpaths (import Button from "lib/Button"em vez deimport { Button } from "lib"). - Polyfills para navegadores modernos — Verifique se você está enviando polyfills para
Promise,fetchouArray.prototype.includes. Em 2026, você não precisa deles. - Feature flags mortas — Caminhos de código por trás de feature flags que estão "ligadas" há seis meses. Remova a flag e o branch morto.
Eu uso o bundle analyzer do Next.js para encontrar chunks grandes demais:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// sua config
};
export default process.env.ANALYZE === "true"
? withBundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig;ANALYZE=true npm run buildIsso abre um treemap visual dos seus bundles. Encontrei uma biblioteca de formatação de data de 120KB que substituí pelo Intl.DateTimeFormat nativo. Encontrei um parser de markdown de 90KB importado em uma página que não usava markdown. Pequenas vitórias que se somam.
CSS que Bloqueia Renderização#
Mencionei isso na seção de LCP, mas vale repetir porque é muito comum. Todo <link rel="stylesheet"> no <head> bloqueia a renderização. Se você tem cinco stylesheets, o navegador espera por todas as cinco antes de pintar qualquer coisa.
O Next.js com Tailwind lida bem com isso — CSS é inline e mínimo. Mas se você está importando CSS de terceiros, audite:
// RUIM — carrega CSS inteiro da biblioteca em toda página
import "some-library/dist/styles.css";
// MELHOR — import dinâmico para que só carregue nas páginas que precisam
const SomeComponent = dynamic(
() => import("some-library").then((mod) => {
// CSS é importado dentro do componente dinâmico
import("some-library/dist/styles.css");
return mod.SomeComponent;
}),
{ ssr: false }
);Uma História Real de Otimização#
Deixe-me mostrar a otimização real da página de ferramentas deste site. É uma página com 15+ ferramentas interativas, cada uma com seu próprio componente, e algumas delas (como o testador de regex e o formatador de JSON) são pesadas em JavaScript.
O Ponto de Partida#
Medições iniciais (dados CrUX, mobile, percentil 75):
- LCP: 3.8s — Ruim
- CLS: 0.12 — Precisa Melhorar
- INP: 340ms — Ruim
Score do Lighthouse: 62.
A Investigação#
Análise do LCP: O elemento LCP era o heading da página (<h1>), que deveria renderizar instantaneamente. Mas estava atrasado por:
- Um arquivo CSS de 200KB de uma biblioteca de componentes (bloqueava a renderização)
- Uma fonte customizada carregada via CDN do Google Fonts (FOIT por 800ms em conexões lentas)
- TTFB de 420ms porque a página era renderizada no servidor a cada requisição sem cache
Análise do CLS: Três fontes:
- Troca de fonte do fallback do Google Fonts para a fonte customizada: 0.06
- Cards de ferramentas carregando sem reserva de altura: 0.04
- Um banner de cookies injetado no topo da página, empurrando tudo para baixo: 0.02
Análise do INP: A ferramenta de teste de regex era a pior ofensora. Cada tecla no input do regex disparava:
- Um re-render completo de todo o componente da ferramenta
- Avaliação do regex contra a string de teste
- Syntax highlighting do padrão regex
Tempo total por tecla: 280-400ms.
As Correções#
Semana 1: LCP e CLS
-
Substituí o CDN do Google Fonts por
next/font. A fonte agora é self-hosted, carregada no build time, com fallback ajustado em tamanho. CLS de fontes: 0.06 → 0.00 -
Removi o CSS da biblioteca de componentes. Reescrevi os 3 componentes que estava usando dela com Tailwind. Total de CSS removido: 180KB. CSS que bloqueia renderização: eliminado
-
Adicionei
revalidate = 3600na página de ferramentas e nas páginas de detalhe. Primeiro hit é renderizado no servidor, hits subsequentes servem do cache. TTFB: 420ms → 45ms (cacheado) -
Adicionei dimensões explícitas a todos os componentes de card de ferramenta e usei
aspect-ratiopara layouts responsivos. CLS de cards: 0.04 → 0.00 -
Movi o banner de cookies para
position: fixedna parte inferior da tela. CLS do banner: 0.02 → 0.00
Semana 2: INP
- Envolvi a computação de resultado do testador de regex em
startTransition:
function RegexTester() {
const [pattern, setPattern] = useState("");
const [testString, setTestString] = useState("");
const [results, setResults] = useState<RegexResult[]>([]);
const [isPending, startTransition] = useTransition();
function handlePatternChange(value: string) {
setPattern(value); // Urgente: atualiza o input
startTransition(() => {
// Não-urgente: computa matches
try {
const regex = new RegExp(value, "g");
const matches = [...testString.matchAll(regex)];
setResults(
matches.map((m) => ({
match: m[0],
index: m.index ?? 0,
groups: m.groups,
}))
);
} catch {
setResults([]);
}
});
}
return (
<div>
<input
value={pattern}
onChange={(e) => handlePatternChange(e.target.value)}
className={isPending ? "opacity-70" : ""}
/>
{/* renderização dos resultados */}
</div>
);
}INP no testador de regex: 380ms → 85ms
-
Adicionei debouncing ao handler de input do formatador de JSON (300ms de delay). INP no formatador de JSON: 260ms → 60ms
-
Movi a computação do gerador de hash para um Web Worker. Hashing SHA-256 de inputs grandes agora acontece fora da main thread. INP no gerador de hash: 200ms → 40ms
Os Resultados#
Depois de duas semanas de otimização (dados CrUX, mobile, percentil 75):
- LCP: 3.8s → 1.8s (melhoria de 53%)
- CLS: 0.12 → 0.00 (melhoria de 100%)
- INP: 340ms → 110ms (melhoria de 68%)
Score do Lighthouse: 62 → 97.
Todas as três métricas solidamente na faixa "Bom". A página parece instantânea no mobile. E o tráfego de busca orgânica aumentou 12% no mês seguinte às melhorias (embora eu não possa provar causalidade — outros fatores estavam em jogo).
O Checklist#
Se você não levar mais nada deste post, aqui está o checklist que eu rodo em todo projeto:
LCP#
- Identifique o elemento LCP com DevTools
- Adicione
priority(oufetchPriority="high") à imagem LCP - Preload de recursos LCP no
<head> - Elimine CSS que bloqueia renderização
- Self-host de fontes com
next/font - Habilite compressão Brotli/Gzip
- Use geração estática ou ISR onde possível
- Defina headers de cache agressivos para assets estáticos
CLS#
- Todas as imagens têm
widtheheightexplícitos - Usando
next/fontcom fallbacks ajustados em tamanho - Conteúdo dinâmico usa
position: fixed/absoluteou espaço reservado - Skeleton screens correspondem às dimensões reais dos componentes
- Sem injeção de conteúdo no topo da página após o carregamento
INP#
- Sem tarefas longas (>50ms) durante handlers de interação
- Atualizações de estado não-urgentes envolvidas em
startTransition - Handlers de input com debounce (300ms)
- Computação pesada delegada a Web Workers
- Tamanho do DOM abaixo de 1.500 nós quando possível
Geral#
- Scripts de terceiros carregados depois da página estar interativa
- Tamanho do bundle analisado e tree-shaken
- CSS não utilizado removido
- Imagens servidas em formato AVIF/WebP
- Monitoramento de usuários reais em produção (biblioteca web-vitals)
Considerações Finais#
Otimização de performance não é uma tarefa pontual. É uma disciplina. Cada nova feature, cada nova dependência, cada novo script de terceiro é uma regressão em potencial. Os sites que permanecem rápidos são aqueles onde alguém está acompanhando as métricas continuamente, não aqueles onde alguém fez um sprint de otimização uma vez.
Configure monitoramento de usuários reais. Configure alertas quando métricas regridem. Torne performance parte do seu processo de code review. Quando alguém adiciona uma biblioteca de 200KB, pergunte se existe uma alternativa de 5KB. Quando alguém adiciona uma computação síncrona em um event handler, pergunte se pode ser adiada ou movida para um worker.
As técnicas neste post não são teóricas. São o que eu realmente fiz, neste site, com números reais para sustentá-las. Seus resultados vão variar — cada site é diferente, cada audiência é diferente, cada infraestrutura é diferente. Mas os princípios são universais: carregue menos, carregue de forma inteligente, não bloqueie a main thread.
Seus usuários não vão te mandar uma mensagem de agradecimento por um site rápido. Mas eles vão ficar. Vão voltar. E o Google vai notar.