Pular para o conteúdo
upgbp

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.

8 min de leitura

Ambiente testado

  • Next.js 15.3
  • next/font
  • Chrome DevTools
  • IBM Plex Sans
Neste artigo (9)

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

PageSpeed Insights · mobile
LCP   4.1 s   ✘ Ruim
CLS   0.14    ✘ Ruim
INP   112 ms  ✔ Bom

Elemento LCP:  h1.titulo

Duas 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:

Chrome DevTools · Network
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)
                                              ↑ LCP

Quatro 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.

Acima, quatro requisições em série — HTML, CSS da página, CSS do Google Fonts e o arquivo da fonte — com o LCP em 3,98 s. Abaixo, com self-host e preload, a fonte baixa em paralelo com o CSS e o LCP cai para 0,9 s.
Com a fonte no próprio domínio e precarregada, a cadeia deixa de ser sequencial.

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

  1. 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/google faz 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.tsx
    import { 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/local faz o mesmo com o .woff2 que você já tem.

  2. 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.

  3. 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/font calcula esses números automaticamente para cada par de fontes — é o principal motivo para usá-lo em vez de escrever @font-face na mão. Para o cálculo manual, a ferramenta de referência é o fontaine.

  4. Reduzir o arquivo que precisa chegar.

    Três medidas, em ordem de retorno:

    Subconjunto certo. latin cobre todos os acentos do português. latin-ext existe 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.

  5. 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:

src/components/MedirLcp.tsx
'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:

Chrome DevTools · Network
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 s

Confirme 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