LCP travado por fonte: preload, font-display e métricas de fallback
Quando o maior elemento da tela é texto, o LCP espera a fonte chegar. Como fazer o texto pintar de imediato sem trocar LCP por deslocamento de layout.
Ambiente testado
- Next.js 15.3
- next/font
- Chrome DevTools
- IBM Plex Sans
Contexto: o que estava rodando
Uma página de captação com um título grande, um parágrafo e um botão. Sem imagem acima da dobra, sem vídeo, sem carrossel. A página mais simples do projeto — e a de pior LCP.
O sintoma
LCP 4.1 s ✘ Ruim
CLS 0.14 ✘ Ruim
INP 112 ms ✔ Bom
Elemento LCP: h1.tituloDuas métricas ruins ao mesmo tempo, e o elemento apontado é um h1. Nenhuma
imagem envolvida. Todo o problema estava na fonte.
Diagnóstico
Texto só conta como pintado quando aparece
O LCP marca o instante em que o maior elemento visível termina de ser desenhado. Quando esse elemento é texto, o navegador precisa da fonte para desenhá-lo.
Com font-display: block ou auto — o padrão quando você não declara nada — o
navegador esconde o texto enquanto a fonte carrega. O usuário vê espaço em
branco, e o LCP só é registrado quando a fonte chega. É o comportamento
conhecido como FOIT: flash of invisible text.
A cadeia de requisições
Ligando a aba Network com throttling de 4G lento, a sequência ficou visível:
1 documento HTML ................ 0 ms → 420 ms
2 CSS da página ................. 430 ms → 890 ms (descoberto no HTML)
3 CSS do Google Fonts .......... 900 ms → 1620 ms (descoberto no CSS)
4 arquivo .woff2 da fonte ...... 1640 ms → 3980 ms (descoberto no CSS 3)
↑ LCPQuatro requisições em série, cada uma só descoberta depois que a anterior chegou. E as duas últimas em outro domínio, o que acrescenta resolução de DNS, handshake de TLS e nova conexão antes do primeiro byte.
Nada disso pode ser paralelizado enquanto a fonte for descoberta dentro de um CSS que está dentro de outro CSS.

E o CLS veio do remédio errado
A primeira tentativa foi trocar para font-display: swap. O texto passou a
aparecer imediatamente na fonte de sistema, e o LCP caiu de 4,1 s para 1,3 s.
Só que a fonte de fallback tem larguras e alturas diferentes. Quando a fonte real chegava, o texto se recompunha: o título mudava de tamanho, o parágrafo pulava uma linha, o botão descia. CLS foi de 0,02 para 0,14.
A solução
-
Hospedar a fonte no próprio domínio.
Isso elimina a requisição de CSS externo, a resolução de DNS e o handshake de um segundo servidor. A cadeia de quatro passos vira três, e todos na mesma conexão já aberta.
No Next.js,
next/font/googlefaz isso em tempo de build — a fonte é baixada durante a compilação e servida do seu domínio. O nome engana: nada é buscado no Google em produção.src/app/layout.tsximport { IBM_Plex_Sans } from 'next/font/google'; const plex = IBM_Plex_Sans({ weight: ['400', '600'], subsets: ['latin'], // português cabe no latin; latin-ext é peso extra display: 'swap', preload: true, variable: '--fonte-corpo', }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="pt-BR" className={plex.variable}> <body>{children}</body> </html> ); }Para arquivo próprio,
next/font/localfaz o mesmo com o.woff2que você já tem. -
Fazer o navegador descobrir a fonte cedo.
Com
preload: true, a tag entra no HTML e a fonte começa a baixar em paralelo com o CSS, em vez de depois dele. Escrito à mão, é isto:head do documento<link rel="preload" href="/fontes/plex-latin-400.woff2" as="font" type="font/woff2" crossorigin />O
crossoriginé obrigatório mesmo em fonte do próprio domínio — requisições de fonte são anônimas por especificação, e sem o atributo o navegador baixa o arquivo duas vezes. -
Alinhar as métricas da fonte de fallback.
Esta é a parte que resolve o CLS. O CSS permite ajustar a fonte de sistema para ocupar exatamente o mesmo espaço que a fonte real vai ocupar. Quando a troca acontece, nada se move.
src/styles/fontes.css@font-face { font-family: 'Plex Fallback'; src: local('Arial'); /* Números calculados para esta combinação específica de fontes. Errados, eles pioram o CLS em vez de corrigir. */ size-adjust: 99.6%; ascent-override: 102.9%; descent-override: 27.6%; line-gap-override: 0%; } body { font-family: 'IBM Plex Sans', 'Plex Fallback', system-ui, sans-serif; }O
next/fontcalcula esses números automaticamente para cada par de fontes — é o principal motivo para usá-lo em vez de escrever@font-facena mão. Para o cálculo manual, a ferramenta de referência é ofontaine. -
Reduzir o arquivo que precisa chegar.
Três medidas, em ordem de retorno:
Subconjunto certo.
latincobre todos os acentos do português.latin-extexiste para línguas do leste europeu e pode dobrar o número de arquivos sem servir para nada.Só os pesos usados. Cada peso é um arquivo. Um projeto que declara 400/500/600/700 em normal e itálico baixa oito arquivos; a maioria das páginas usa dois.
WOFF2 e nada mais. Todo navegador relevante suporta. Manter WOFF ou TTF como alternativa só aumenta o CSS.
-
Se o LCP for imagem, o caminho é outro.
Vale registrar porque o diagnóstico é o mesmo e a correção não:
import Image from 'next/image'; // priority injeta preload e fetchpriority="high"; sem ele a imagem entra // na fila junto com o resto e o LCP espera. <Image src={heroi} alt="" priority sizes="100vw" />
Como confirmar que resolveu
Meça o LCP direto no navegador, sem depender do PageSpeed:
'use client';
import { useEffect } from 'react';
export function MedirLcp() {
useEffect(() => {
new PerformanceObserver((lista) => {
const ultima = lista.getEntries().at(-1) as PerformanceEntry & { element?: Element };
console.log('[LCP]', Math.round(ultima.startTime), 'ms', ultima.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
}, []);
return null;
}Confira a cadeia encurtada. Na aba Network, a fonte deve começar a baixar junto com o CSS, não depois:
1 documento HTML ............. 0 ms → 410 ms
2 CSS ....................... 420 ms → 640 ms
2 fonte .woff2 (preload) .... 420 ms → 780 ms ← paralelo, mesma conexão
↑ LCP 0,9 sConfirme que o CLS não subiu. Force a fonte a demorar para ver o momento da troca: no DevTools, Network com throttling Slow 3G, recarregue e observe se algo se move quando a fonte entra. Movimento nenhum é o resultado esperado.
Compare com a fonte propositalmente quebrada. Bloqueie o arquivo .woff2 em
Network → Block request URL e recarregue. O layout tem que ficar
idêntico, só com outra fonte. Se mudar, as métricas de fallback estão
erradas.
Armadilhas que sobram depois disso
preload de fonte não usada é peso puro. Ela compete com o CSS e com a
imagem principal por banda. Se uma fonte precarregada não aparece acima da dobra,
tire o preload.
Fonte variável nem sempre compensa. Um arquivo variável cobre todos os pesos, mas pesa mais que um único estático. Se a página usa dois pesos, dois estáticos costumam ser menores que um variável.
font-display: optional é a opção mais radical. Ela dá à fonte uma janela
muito curta; se não chegar, a página fica no fallback até a próxima navegação.
CLS vai a zero e o LCP é o melhor possível — ao custo de parte dos usuários nunca
ver sua tipografia.
Ícones em fonte bloqueiam do mesmo jeito. Uma fonte de ícones acima da dobra entra na mesma disputa. SVG inline não tem esse custo.
Nem todo LCP ruim é culpa da fonte. Se o TTFB já for alto, nenhuma otimização de tipografia resolve — o servidor está segurando a resposta antes de o navegador ter o que desenhar, e o problema é outro.
O relatório de campo demora. O CrUX trabalha com janela de 28 dias. Valide com medição própria no dia do deploy e use o Search Console para confirmar semanas depois.
Continue por aqui
Performance Web
INP acima de 200 ms: achando a long task que trava o clique
O INP mede o clique mais lento da sessão, não a média. Como achar exatamente qual interação e qual linha de código estão travando a thread principal.
Performance Web
Por que este blog sobre Next.js roda em Astro
A escolha não foi ideológica: um site de conteúdo tem exigências diferentes das de um aplicativo. Os números deste site e onde o Next.js ganharia.
Performance Web
Web Push do zero: Service Worker, VAPID e o pedido de permissão
A implementação inteira, do registro do worker ao envio. E o erro de UX que queima a permissão do usuário para sempre, sem possibilidade de desfazer.