Core Web Vitals e Lighthouse: Do 60 ao 100 em Performance
LCP, CLS e INP com thresholds e causas reais. next/image para evitar CLS, next/font para eliminar FOUT, code splitting e lazy loading, Resource Hints (preconnect, preload) e medição com Lighthouse CI no GitHub Actions.
Desde 2021, o Google usa os Core Web Vitals como fator de ranqueamento no Search. Um site lento não apenas frustra usuários — ele perde posição nos resultados orgânicos para concorrentes mais rápidos. Uma queda de 100ms no Largest Contentful Paint pode reduzir conversões em até 8% em e-commerces, de acordo com estudos do Google.
O Lighthouse audita performance, acessibilidade, SEO e boas práticas em uma única ferramenta. Este artigo foca nas métricas de performance: como medi-las, interpretá-las e corrigi-las de forma sistemática.
As Três Métricas Core Web Vitals (2024)
- LCP (Largest Contentful Paint) — tempo até o maior elemento visível renderizar completamente. Geralmente uma imagem hero, um bloco de texto H1, ou um vídeo. Bom: < 2.5s | Necessita melhoria: 2.5s–4s | Ruim: > 4s. Causas comuns: imagem hero não priorizada, servidor lento, renderização bloqueada por CSS/JS.
- CLS (Cumulative Layout Shift) — soma dos shifts de layout inesperados durante a vida da página. Um anúncio que aparece empurrando o conteúdo para baixo gera CLS alto. Bom: < 0.1 | Necessita melhoria: 0.1–0.25 | Ruim: > 0.25. Causas: imagens sem dimensões, fontes causando FOUT, iframes sem tamanho definido.
- INP (Interaction to Next Paint) — substituiu o FID em 2024. Mede a latência de toda interação do usuário (clique, toque, teclado) durante a sessão, não apenas a primeira. Bom: < 200ms | Necessita melhoria: 200ms–500ms | Ruim: > 500ms. Causas: JavaScript pesado bloqueando a main thread, handlers síncronos lentos, reflows desnecessários.
Corrigindo CLS: Dimensões em Imagens e Fontes
O CLS causado por imagens é o mais fácil de corrigir e um dos mais comuns: sem width e height declarados, o browser não reserva espaço para a imagem antes de carregá-la. Quando a imagem chega, ela empurra todo o conteúdo abaixo — um shift visível e irritante. O next/image resolve automaticamente ao exigir width e height (ou fill), além de converter para WebP/AVIF, fazer lazy loading por padrão e exibir um blur placeholder durante o carregamento.
import Image from 'next/image';
// ERRADO: sem width/height, o browser não reserva espaço
// Quando a imagem carrega, empurra o conteúdo abaixo → CLS alto
<img src="/hero.jpg" alt="Banner" />
// CORRETO com next/image:
// - Reserva o espaço automaticamente pelo aspect ratio
// - Converte para WebP/AVIF automaticamente
// - Lazy loading por padrão (exceto above-the-fold)
// - Blur placeholder durante o carregamento
<Image
src="/hero.jpg"
alt="Banner principal da loja"
width={1200}
height={630}
priority // Prioriza o carregamento (LCP image — acima da dobra)
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
sizes="(max-width: 768px) 100vw, 1200px"
/>
// Para imagens de tamanho variável (ex: fill container):
<div className="relative h-64 w-full">
<Image
src="/product.jpg"
alt="Produto"
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover"
/>
</div>O CLS causado por fontes é mais sutil: o browser primeiro renderiza o texto com uma fonte de sistema (fallback), e quando a fonte customizada carrega, substitui — causando um reflow que muda o tamanho e posição do texto. Isso é o FOUT (Flash of Unstyled Text). O next/font elimina isso completamente: a fonte é baixada no build e servida localmente, eliminando a requisição externa e tornando o carregamento síncrono com o CSS da página.
// ERRADO: Google Fonts via <link> — bloqueia renderização e causa FOUT
// <link href="https://fonts.googleapis.com/css2?family=Inter..." />
// CORRETO: next/font baixa a fonte no BUILD e serve localmente
// Zero requisição externa do browser, zero FOUT (Flash of Unstyled Text)
import { Inter, Fira_Code } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Garante texto visível durante o carregamento
variable: '--font-inter', // Variável CSS para uso no Tailwind/CSS
});
const firaCode = Fira_Code({
subsets: ['latin'],
weight: ['400', '500'],
variable: '--font-fira-code',
});
// No layout.tsx:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" className={`${inter.variable} ${firaCode.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}Corrigindo LCP: Resource Hints
Os Resource Hints instruem o browser a preparar conexões ou baixar recursos antes de precisar deles. O preconnect elimina a latência de estabelecer a conexão TCP/TLS com servidores de terceiros (CDNs, APIs de fontes) — uma economia de 100-500ms dependendo da latência de rede. O preload sinaliza ao browser para baixar um recurso específico com prioridade máxima, o que é essencial para a imagem LCP: sem preload, o browser só descobre a imagem quando processa o HTML e chega até o <img>, perdendo tempo precioso.
// Para recursos críticos de terceiros (ex: CDN de fontes, API externa)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<head>
{/* preconnect: abre a conexão TCP/TLS com o servidor antes de precisar
Use para origens de terceiros que você sabe que vai usar */}
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* preload: instrui o browser a baixar um recurso com alta prioridade
Use para a imagem LCP ou font crítica */}
<link
rel="preload"
href="/hero.jpg"
as="image"
type="image/jpeg"
/>
{/* dns-prefetch: resolve o DNS antecipadamente (menos potente que preconnect) */}
<link rel="dns-prefetch" href="https://analytics.example.com" />
</head>
<body>{children}</body>
</html>
);
}Corrigindo INP: Code Splitting e Lazy Loading
O INP alto é causado por JavaScript bloqueando a main thread. O remédio principal é reduzir o bundle inicial: qualquer componente que não é visível acima da dobra (above-the-fold) na primeira renderização não precisa estar no bundle inicial. O dynamic() do Next.js implementa isso com code splitting automático — o componente pesado é baixado apenas quando necessário, mantendo o bundle principal leve e o INP baixo.
import dynamic from 'next/dynamic';
// Componentes abaixo da dobra ou condicional: lazy load
// O bundle principal não inclui o código deste componente
const HeavyChart = dynamic(
() => import('./HeavyChart'),
{
loading: () => <div className="animate-pulse h-64 bg-gray-200 rounded" />,
ssr: false, // Para componentes que dependem de APIs de browser
}
);
// Modal: só carrega quando o usuário abre
const PaymentModal = dynamic(() => import('./PaymentModal'));
export default function Dashboard() {
const [showModal, setShowModal] = useState(false);
return (
<div>
{/* HeavyChart: carrega lazy quando entra na viewport */}
<HeavyChart data={chartData} />
{/* PaymentModal: carrega apenas quando aberto */}
{showModal && <PaymentModal onClose={() => setShowModal(false)} />}
</div>
);
}Lighthouse CI no GitHub Actions
O maior erro com Lighthouse é usá-lo como verificação manual e pontual. Uma regressão de performance pode ser introduzida em qualquer PR — um novo componente pesado, uma imagem sem priority, um third-party script adicionado. O Lighthouse CI automatiza a verificação: roda o Lighthouse em cada PR, compara com os thresholds definidos e reprova o CI se alguma métrica piorar. Isso torna a performance uma propriedade do código que o time inteiro mantém, não responsabilidade de uma pessoa.
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v11
with:
urls: |
http://localhost:3000
http://localhost:3000/products
# Falha o CI se as métricas ficarem abaixo dos thresholds
budgetPath: ./lighthouserc.json
uploadArtifacts: true
- name: Comment PR with results
uses: actions/github-script@v7
# ... comentário automático no PR com os scores{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:seo": ["error", { "minScore": 0.9 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
}
}
}
}Sempre execute o Lighthouse em aba anônima e em modo Mobile (throttling de CPU 4x e rede 3G lenta). Esses são os thresholds que o Google usa para ranqueamento. Um score de 100 no Desktop e 50 no Mobile não protege seu SEO.
Conclusão
Performance não é um feature que você adiciona no final — é uma consequência de decisões de arquitetura tomadas durante o desenvolvimento. next/image e next/font eliminam as causas mais comuns de CLS. priority na imagem LCP melhora diretamente o LCP. dynamic() mantém o bundle inicial enxuto e o INP baixo. E o Lighthouse CI no GitHub Actions garante que nenhum PR piore as métricas sem que o time perceba.