Performance não é detalhe. É diferença entre converter ou perder o usuário.
Dados do Google (2023):
- Site carrega em 1s → Conversão 100% (baseline)
- Site carrega em 3s → Conversão -32%
- Site carrega em 5s → Conversão -53%
- Site carrega em 10s → Conversão -90%
Core Web Vitals = métricas oficiais do Google que afetam SEO e conversão.
Este artigo mostra como otimizar LCP, FID e CLS de “poor” para “good”.
As 3 métricas Core Web Vitals
1. LCP (Largest Contentful Paint)
O que é: Tempo até elemento principal estar visível.
Thresholds:
- ✅ Good: menor que 2,5s
- ⚠️ Needs improvement: 2,5-4s
- ❌ Poor: maior que 4s
Elementos que contam como LCP:
<img>tags<image>dentro de<svg><video>poster- Elemento com background-image
- Bloco de texto
Como medir:
// lib/web-vitals.ts
import { onLCP } from 'web-vitals'
onLCP((metric) => {
console.log('LCP:', metric.value, 'ms')
// Envia para analytics
gtag('event', 'web_vitals', {
event_category: 'Web Vitals',
event_label: 'LCP',
value: Math.round(metric.value),
non_interaction: true
})
})
Otimizações:
A) Priorizar imagem hero (above the fold):
// components/HeroImage.tsx
import Image from 'next/image'
export function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1920}
height={1080}
priority // ← Carrega antes de tudo
quality={90}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Low quality placeholder
/>
)
}
B) Preload critical resources:
// app/layout.tsx
export default function RootLayout({ children }: Props) {
return (
<html>
<head>
{/* Preload fontes */}
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
{/* Preconnect a APIs externas */}
<link rel="preconnect" href="https://api.suaempresa.com" />
<link rel="dns-prefetch" href="https://cdn.suaempresa.com" />
</head>
<body>{children}</body>
</html>
)
}
C) Otimizar imagens (WebP + tamanhos corretos):
# Converte PNG/JPG para WebP (70-80% menor)
npx @squoosh/cli --webp auto images/*.{png,jpg}
// next.config.js
module.exports = {
images: {
formats: ['image/webp', 'image/avif'], // Formatos modernos
deviceSizes: [640, 750, 828, 1080, 1200, 1920], // Responsive
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 31536000, // 1 ano
}
}
D) Self-host critical fonts:
/* app/globals.css */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap; /* Mostra fallback font até carregar */
font-style: normal;
}
Resultado:
- Antes: LCP 3,8s (Poor) - Google Fonts externo + PNG 2MB
- Depois: LCP 0,9s (Good) - Self-hosted font + WebP 180KB
- Melhoria: -76% (3x mais rápido)
2. FID (First Input Delay)
O que é: Tempo até site responder à primeira interação (clique, tap).
Thresholds:
- ✅ Good: menor que 100ms
- ⚠️ Needs improvement: 100-300ms
- ❌ Poor: maior que 300ms
Causa comum: JavaScript bloqueando thread principal.
Como medir:
import { onFID } from 'web-vitals'
onFID((metric) => {
console.log('FID:', metric.value, 'ms')
})
Otimizações:
A) Code splitting (carregar apenas JS necessário):
// app/dashboard/page.tsx
import dynamic from 'next/dynamic'
// Lazy load componente pesado
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false // Não renderiza no servidor
})
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<HeavyChart /> {/* Carrega apenas quando necessário */}
</div>
)
}
B) Defer de scripts não-críticos:
// app/layout.tsx
<Script
src="https://chat.suporte.com/widget.js"
strategy="lazyOnload" // Carrega após página estar pronta
/>
<Script
src="https://analytics.com/script.js"
strategy="afterInteractive" // Carrega após interatividade
/>
C) Web Workers para processamento pesado:
// workers/data-processor.ts
self.addEventListener('message', (e) => {
const { data } = e.data
// Processamento pesado (não bloqueia UI)
const result = complexCalculation(data)
self.postMessage({ result })
})
// Uso
const worker = new Worker('/workers/data-processor.js')
worker.postMessage({ data: largeDataset })
worker.addEventListener('message', (e) => {
const { result } = e.data
setProcessedData(result)
})
Resultado:
- Antes: FID 280ms (Poor) - Bundle 800KB, tudo eager-loaded
- Depois: FID 45ms (Good) - Bundle 180KB inicial, lazy-loading
- Melhoria: -84%
3. CLS (Cumulative Layout Shift)
O que é: Instabilidade visual (elementos pulando durante carregamento).
Thresholds:
- ✅ Good: menor que 0,1
- ⚠️ Needs improvement: 0,1-0,25
- ❌ Poor: maior que 0,25
Causas comuns:
- Imagens sem width/height
- Ads/embeds dinâmicos
- Fontes causando FOIT (Flash of Invisible Text)
- Conteúdo injetado acima do fold
Como medir:
import { onCLS } from 'web-vitals'
onCLS((metric) => {
console.log('CLS:', metric.value)
// Identifica elementos causando shift
metric.entries.forEach((entry) => {
console.log('Layout shift:', entry)
})
})
Otimizações:
A) Definir dimensões em imagens:
// ❌ ERRADO (causa CLS)
<img src="/produto.jpg" alt="Produto" />
// ✅ CORRETO
<Image
src="/produto.jpg"
alt="Produto"
width={400}
height={300}
placeholder="blur"
/>
B) Reservar espaço para ads/embeds:
// components/AdSlot.tsx
export function AdSlot() {
return (
<div
className="ad-container"
style={{
minHeight: '250px', // Reserva espaço
width: '300px',
backgroundColor: '#f0f0f0' // Placeholder
}}
>
{/* Ad carrega aqui */}
<script async src="https://ads.com/script.js" />
</div>
)
}
C) Font-display: swap (evita FOIT):
@font-face {
font-family: 'Custom';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* Mostra fallback, depois troca */
}
D) Skeleton screens (placeholders):
// components/Skeleton.tsx
export function ProductCardSkeleton() {
return (
<div className="animate-pulse">
<div className="bg-gray-300 h-48 w-full mb-4"></div>
<div className="bg-gray-300 h-4 w-3/4 mb-2"></div>
<div className="bg-gray-300 h-4 w-1/2"></div>
</div>
)
}
// Uso
{isLoading ? <ProductCardSkeleton /> : <ProductCard product={product} />}
Resultado:
- Antes: CLS 0,38 (Poor) - Imagens sem dimensões, FOIT
- Depois: CLS 0,04 (Good) - Aspect ratios, font-display:swap
- Melhoria: -89%
Bundle size optimization
Análise de bundle:
# Next.js bundle analyzer
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
})
module.exports = withBundleAnalyzer({
// config
})
# Roda análise
ANALYZE=true npm run build
Otimizações comuns:
A) Tree-shaking (remover código não usado):
// ❌ ERRADO (importa TUDO do lodash - 70KB)
import _ from 'lodash'
const result = _.debounce(fn, 300)
// ✅ CORRETO (importa apenas debounce - 3KB)
import debounce from 'lodash/debounce'
const result = debounce(fn, 300)
B) Remover dependências pesadas:
# Antes: moment.js (288KB)
npm uninstall moment
# Depois: date-fns (modular, 12KB)
npm install date-fns
// Antes
import moment from 'moment'
const formatted = moment(date).format('DD/MM/YYYY')
// Depois
import { format } from 'date-fns'
const formatted = format(date, 'dd/MM/yyyy')
C) Dynamic imports (código assíncrono):
// Antes: Importa Stripe no bundle inicial (+87KB)
import { loadStripe } from '@stripe/stripe-js'
// Depois: Carrega apenas quando necessário
const handlePayment = async () => {
const { loadStripe } = await import('@stripe/stripe-js')
const stripe = await loadStripe(STRIPE_KEY)
// ...
}
Caching strategies
1. Static Generation (SSG)
Quando usar: Conteúdo que muda raramente (landing pages, blog).
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({
slug: post.slug
}))
}
export default async function BlogPost({ params }: Props) {
const post = await getPost(params.slug)
return <Article post={post} />
}
// Revalidate cache a cada 1h
export const revalidate = 3600
2. Server-Side Rendering (SSR) com cache
// app/dashboard/page.tsx
import { unstable_cache } from 'next/cache'
const getCachedData = unstable_cache(
async (userId) => {
return await fetchUserData(userId)
},
['user-dashboard'],
{ revalidate: 300 } // Cache por 5min
)
export default async function Dashboard() {
const data = await getCachedData(userId)
return <DashboardView data={data} />
}
3. Client-side caching (SWR)
// hooks/use-bookings.ts
import useSWR from 'swr'
export function useBookings(userId: string) {
const { data, error, isLoading } = useSWR(
`/api/bookings?userId=${userId}`,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache 1min
refreshInterval: 300000 // Auto-refresh 5min
}
)
return { bookings: data, error, isLoading }
}
4. HTTP caching headers
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable' // 1 ano
}
]
},
{
source: '/api/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=60, stale-while-revalidate=300'
}
]
}
]
}
}
CDN e edge caching
Vercel (Next.js):
// Configuração automática de edge caching
export const runtime = 'edge' // Roda em 300+ datacenters
// Cache agressivo em assets estáticos
export const revalidate = false // Never revalidate
Cloudflare CDN (assets estáticos):
# cloudflare-cache-rules.txt
/*.css cache 1 year
/*.js cache 1 year
/*.woff2 cache 1 year
/images/* cache 1 month
/api/* cache 5 minutes
Checklist de performance
Antes de lançar:
- LCP menor que 2,5s (teste no PageSpeed Insights)
- FID menor que 100ms
- CLS menor que 0,1
- First bundle menor que 200KB (gzip)
- Todas as imagens em WebP/AVIF
- Lazy loading de imagens below-the-fold
- Fontes self-hosted (ou com font-display:swap)
- Code splitting implementado
- HTTP/2 ou HTTP/3 ativo
- Gzip/Brotli compression ativo
Monitoramento contínuo:
- Web Vitals tracking (Mixpanel/GA4)
- Lighthouse CI em cada deploy
- Real User Monitoring (RUM) ativo
- Alertas para regressões de performance
Case real: E-commerce que aumentou conversão em 42%
Perfil: Loja online de roupas (50K visitantes/mês).
Problema (métricas iniciais):
- LCP: 4,2s (Poor) ← Hero image 3,5MB PNG
- FID: 320ms (Poor) ← Bundle 950KB
- CLS: 0,28 (Poor) ← Imagens sem dimensões
- Bounce rate: 68%
- Conversão: 1,8%
Otimizações implementadas (3 semanas):
Semana 1: Imagens
- Converteu para WebP (3,5MB → 280KB, -92%)
- Adicionou
priorityem hero - Lazy loading abaixo do fold
Semana 2: JavaScript
- Code splitting (950KB → 180KB inicial)
- Dynamic imports (Stripe, analytics)
- Removeu libraries não usadas
Semana 3: Layout
- Definiu aspect ratios em imagens
- Skeleton screens
- Font-display:swap
Resultado (3 meses após):
| Métrica | Antes | Depois | Variação |
|---|---|---|---|
| LCP | 4,2s | 1,1s | -74% |
| FID | 320ms | 52ms | -84% |
| CLS | 0,28 | 0,05 | -82% |
| Bounce rate | 68% | 39% | -43% |
| Conversão | 1,8% | 2,9% | +61% |
| Receita/mês | R$ 86K | R$ 142K | +65% |
ROI:
- Investimento: R$ 18K (3 semanas de dev)
- Ganho mensal: R$ 56K (receita extra)
- Payback: menor que 2 semanas
Ferramentas de análise
1. PageSpeed Insights: https://pagespeed.web.dev
- Mostra Core Web Vitals
- Dicas de otimização
- Teste mobile e desktop
2. Lighthouse CI (integração com CI/CD):
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v9
with:
urls: |
https://preview-${{ github.event.number }}.vercel.app
uploadArtifacts: true
temporaryPublicStorage: true
3. WebPageTest: https://webpagetest.org
- Teste em devices reais
- Filmstrip (loading visual)
- Waterfall de requests
4. Chrome DevTools:
- Performance tab (profiling)
- Coverage tab (código não usado)
- Network tab (payload analysis)
Próximos passos
- Medir baseline (PageSpeed Insights)
- Otimizar LCP (imagens, fonts, preload)
- Otimizar FID (code splitting, defer)
- Otimizar CLS (aspect ratios, skeletons)
- Setup caching (SSG, SWR, HTTP headers)
- Configurar CDN (Vercel, Cloudflare)
- Monitorar Web Vitals (analytics)
- Lighthouse CI em cada PR
Lembre-se: Cada 100ms economizados = +1% conversão. Performance = receita.