Otimização de Core Web Vitals
Otimização direcionada para as três métricas de Core Web Vitals que afetam o ranking do Google Search e a experiência do usuário.
As três métricas
| Métrica | Mede | Bom | Precisa melhorar | Ruim |
|---|---|---|---|---|
| LCP | Carregamento | ≤ 2,5s | 2,5s – 4s | > 4s |
| INP | Interatividade | ≤ 200ms | 200ms – 500ms | > 500ms |
| CLS | Estabilidade visual | ≤ 0,1 | 0,1 – 0,25 | > 0,25 |
O Google mede no 75º percentil — 75% das visitas de página devem atender aos limites "Bom".
LCP: Largest Contentful Paint
LCP mede quando o maior elemento de conteúdo visível é renderizado. Geralmente é:
- Imagem ou vídeo em destaque
- Bloco de texto grande
- Imagem de fundo
- Elemento
<svg>
Problemas comuns de LCP
1. Resposta lenta do servidor (TTFB > 800ms)
Solução: CDN, cache, backend otimizado, renderização em edge
2. Recursos que bloqueiam renderização
<!-- ❌ Bloqueia renderização -->
<link rel="stylesheet" href="/all-styles.css">
<!-- ✅ CSS crítico incorporado, restante adiado -->
<style>/* CSS crítico acima da dobra */</style>
<link rel="preload" href="/styles.css" as="style"
3. Tempo lento de carregamento de recursos
<!-- ❌ Sem dicas, descoberto tarde -->
<img src="/hero.jpg" alt="Hero">
<!-- ✅ Pré-carregado com alta prioridade -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<img src="/hero.webp" alt="Hero" fetchpriority="high">
4. Atrasos na renderização no cliente
// ❌ Conteúdo carrega após JavaScript
useEffect(() => {
fetch('/api/hero-text').then(r => r.json()).then(setHeroText);
}, []);
// ✅ Renderização no servidor ou estática
// Use SSR, SSG ou streaming para enviar HTML com conteúdo
export async function getServerSideProps() {
const heroText = await fetchHeroText();
return { props: { heroText } };
}
Checklist de otimização de LCP
- [ ] TTFB < 800ms (use CDN, cache em edge)
- [ ] Imagem LCP pré-carregada com fetchpriority="high"
- [ ] Imagem LCP otimizada (WebP/AVIF, tamanho correto)
- [ ] CSS crítico incorporado (< 14KB)
- [ ] Nenhum JavaScript que bloqueie renderização em <head>
- [ ] Fontes não bloqueiam renderização de texto (font-display: swap)
- [ ] Elemento LCP no HTML inicial (não renderizado por JS)
Identificação do elemento LCP
// Encontre seu elemento LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP element:', lastEntry.element);
console.log('LCP time:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
INP: Interaction to Next Paint
INP mede a responsividade em TODAS as interações (cliques, toques, pressionamento de teclas) durante uma visita à página. Ele relata a pior interação (no 98º percentil para páginas de alto tráfego).
Decomposição de INP
Total INP = Input Delay + Processing Time + Presentation Delay
| Fase | Alvo | Otimização |
|---|---|---|
| Input Delay | < 50ms | Reduza bloqueio da thread principal |
| Processing | < 100ms | Otimize manipuladores de eventos |
| Presentation | < 50ms | Minimize trabalho de renderização |
Problemas comuns de INP
1. Tarefas longas bloqueando a thread principal
// ❌ Tarefa síncrona longa
function processLargeArray(items) {
items.forEach(item => expensiveOperation(item));
}
// ✅ Divida em chunks com yielding
async function processLargeArray(items) {
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => expensiveOperation(item));
// Ceda à thread principal
await new Promise(r => setTimeout(r, 0));
// Ou use scheduler.yield() quando disponível
}
}
2. Manipuladores de eventos pesados
// ❌ Todo trabalho no manipulador
button.addEventListener('click', () => {
// Computação pesada
const result = calculateComplexThing();
// Atualizações de DOM
updateUI(result);
// Analytics
trackEvent('click');
});
// ✅ Priorize feedback visual imediato
button.addEventListener('click', () => {
// Feedback visual imediato
button.classList.add('loading');
// Adie trabalho não-crítico
requestAnimationFrame(() => {
const result = calculateComplexThing();
updateUI(result);
});
// Use requestIdleCallback para analytics
requestIdleCallback(() => trackEvent('click'));
});
3. Scripts de terceiros
// ❌ Carregado com entusiasmo, bloqueia interações
<script src="https://heavy-widget.com/widget.js"></script>
// ✅ Carregado com preguiça na interação ou visibilidade
const loadWidget = () => {
import('https://heavy-widget.com/widget.js')
.then(widget => widget.init());
};
button.addEventListener('click', loadWidget, { once: true });
4. Re-renderizações excessivas (React/Vue)
// ❌ Re-renderiza toda a árvore
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} />
<ExpensiveComponent /> {/* Re-renderiza a cada mudança de count */}
</div>
);
}
// ✅ Componentes caros memoizados
const MemoizedExpensive = React.memo(ExpensiveComponent);
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} />
<MemoizedExpensive />
</div>
);
}
Checklist de otimização de INP
- [ ] Nenhuma tarefa > 50ms na thread principal
- [ ] Manipuladores de eventos concluem rapidamente (< 100ms)
- [ ] Feedback visual fornecido imediatamente
- [ ] Trabalho pesado adiado com requestIdleCallback
- [ ] Scripts de terceiros não bloqueiam interações
- [ ] Manipuladores de entrada com debounce quando apropriado
- [ ] Web Workers para operações intensivas de CPU
Depuração de INP
// Identifique interações lentas
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn('Slow interaction:', {
type: entry.name,
duration: entry.duration,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
target: entry.target
});
}
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
CLS: Cumulative Layout Shift
CLS mede deslocamentos de layout inesperados. Um deslocamento ocorre quando um elemento visível muda de posição entre quadros sem interação do usuário.
Fórmula de CLS: impacto de fração × fração de distância
Causas comuns de CLS
1. Imagens sem dimensões
<!-- ❌ Causa deslocamento de layout quando carregada -->
<img src="photo.jpg" alt="Photo">
<!-- ✅ Espaço reservado -->
<img src="photo.jpg" alt="Photo" width="800" height="600">
<!-- ✅ Ou use aspect-ratio -->
<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;">
2. Anúncios, embeds e iframes
<!-- ❌ Tamanho desconhecido até carregar -->
<iframe src="https://ad-network.com/ad"></iframe>
<!-- ✅ Reserve espaço com min-height -->
<div style="min-height: 250px;">
<iframe src="https://ad-network.com/ad" height="250"></iframe>
</div>
<!-- ✅ Ou use container com aspect-ratio -->
<div style="aspect-ratio: 16/9;">
<iframe src="https://youtube.com/embed/..."
style="width: 100%; height: 100%;"></iframe>
</div>
3. Conteúdo injetado dinamicamente
// ❌ Insere conteúdo acima do viewport
notifications.prepend(newNotification);
// ✅ Insira abaixo do viewport ou use transform
const insertBelow = viewport.bottom < newNotification.top;
if (insertBelow) {
notifications.prepend(newNotification);
} else {
// Anime sem deslocar
newNotification.style.transform = 'translateY(-100%)';
notifications.prepend(newNotification);
requestAnimationFrame(() => {
newNotification.style.transform = '';
});
}
4. Fontes web causando FOUT
/* ❌ Troca de fonte desloca texto */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
}
/* ✅ Fonte opcional (sem deslocamento se lenta) */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: optional;
}
/* ✅ Ou combine métricas de fallback */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: swap;
size-adjust: 105%; /* Combine tamanho de fallback */
ascent-override: 95%;
descent-override: 20%;
}
5. Animações disparando layout
/* ❌ Anima propriedades de layout */
.animate {
transition: height 0.3s, width 0.3s;
}
/* ✅ Use transform em vez disso */
.animate {
transition: transform 0.3s;
}
.animate.expanded {
transform: scale(1.2);
}
Checklist de otimização de CLS
- [ ] Todas as imagens têm width/height ou aspect-ratio
- [ ] Todos os vídeos/embeds têm espaço reservado
- [ ] Anúncios têm containers com min-height
- [ ] Fontes usam font-display: optional ou métricas combinadas
- [ ] Conteúdo dinâmico inserido abaixo do viewport
- [ ] Animações usam apenas transform/opacity
- [ ] Nenhum conteúdo injetado acima do conteúdo existente
Depuração de CLS
// Rastreie deslocamentos de layout
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry.value);
entry.sources?.forEach(source => {
console.log(' Shifted element:', source.node);
console.log(' Previous rect:', source.previousRect);
console.log(' Current rect:', source.currentRect);
});
}
}
}).observe({ type: 'layout-shift', buffered: true });
Ferramentas de medição
Testes em laboratório
- Chrome DevTools → Painel Performance, Lighthouse
- WebPageTest → Detalhado waterfall, filmstrip
- Lighthouse CLI →
npx lighthouse <url>
Dados de campo (usuários reais)
- Chrome User Experience Report (CrUX) → BigQuery ou API
- Search Console → Relatório de Core Web Vitals
- web-vitals library → Envie para sua analytics
import {onLCP, onINP, onCLS} from 'web-vitals';
function sendToAnalytics({name, value, rating}) {
gtag('event', name, {
event_category: 'Web Vitals',
value: Math.round(name === 'CLS' ? value * 1000 : value),
event_label: rating
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Correções rápidas por framework
Next.js
// LCP: Use next/image com priority
import Image from 'next/image';
<Image src="/hero.jpg" priority fill alt="Hero" />
// INP: Use importação dinâmica
const HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false });
// CLS: Componente Image cuida automaticamente das dimensões
React
// LCP: Pré-carregue na head
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />
// INP: Memoize e useTransition
const [isPending, startTransition] = useTransition();
startTransition(() => setExpensiveState(newValue));
// CLS: Sempre especifique dimensões em tags img
Vue/Nuxt
<!-- LCP: Use nuxt/image com preload -->
<NuxtImg src="/hero.jpg" preload loading="eager" />
<!-- INP: Use componentes assíncronos -->
<component :is="() => import('./Heavy.vue')" />
<!-- CLS: Use CSS aspect-ratio -->
<img :style="{ aspectRatio: '16/9' }" />