Pular para o conteúdo
upgbp

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.

8 min de leitura

Ambiente testado

  • Next.js 15.3
  • React 19.1
  • web-vitals 5
  • Chrome DevTools
Neste artigo (9)

Contexto: o que estava rodando

Um painel com uma tabela de 1.200 lançamentos e uma barra de filtros por categoria. Clicar num filtro parecia travar a página: o chip só mudava de cor meio segundo depois do clique, e nesse intervalo nada respondia.

O Lighthouse dava verde em tudo. O relatório de campo do Search Console dizia outra coisa.

O sintoma

PageSpeed Insights · dados de campo (75º percentil)
LCP   1.4 s   ✔ Bom
CLS   0.02    ✔ Bom
INP   482 ms  ✘ Ruim          (limite para "Bom": 200 ms)

O Lighthouse não pega isso porque ele carrega a página e mede — ele não clica nos seus filtros. INP é métrica de interação, e só aparece quando alguém interage de verdade.

Diagnóstico

O que o INP realmente mede

INP é o tempo entre o usuário agir e a tela mostrar o resultado. Ele se divide em três partes, e saber qual delas domina muda completamente a correção:

Fase O que é Causa típica
Input delay O clique esperando a thread principal desocupar Script de terceiro, hidratação ainda rodando, outro trabalho em fila
Processing Seu handler executando Filtro caro, atualização de estado que redesenha demais
Presentation O navegador desenhando Layout gigante, recálculo de estilo em árvore grande

E ele reporta o pior caso relevante da sessão, não a média. Uma interação ruim entre cem boas é o que o usuário lembra, e é o que a métrica registra.

O clique dividido em espera, processamento e desenho, antes e depois da correção, com a marca dos 200 milissegundos atravessando as duas barras. Antes, 476 ms dominados por 441 ms de processamento; depois, 88 ms.
Saber qual fase domina muda a correção: era o handler, não script de terceiro nem desenho.

Instrumentar para saber qual interação é

Adivinhar qual clique está lento é perda de tempo. A biblioteca web-vitals tem uma versão que entrega o elemento culpado junto com o número:

src/components/Vitals.tsx
'use client';
import { useEffect } from 'react';

export function Vitals() {
  useEffect(() => {
    import('web-vitals/attribution').then(({ onINP }) => {
      onINP((metric) => {
        const a = metric.attribution;
        console.log('[INP]', Math.round(metric.value), 'ms', {
          alvo: a.interactionTarget,
          tipo: a.interactionType,
          espera: Math.round(a.inputDelay),
          processamento: Math.round(a.processingDuration),
          desenho: Math.round(a.presentationDelay),
        });
      });
    });
  }, []);

  return null;
}

O import dinâmico mantém a biblioteca fora do pacote inicial — medir performance não pode custar performance.

Em produção, troque o console.log por um envio ao seu coletor. Foi assim que a resposta apareceu:

console do navegador
[INP] 476 ms { alvo: 'button.chip[data-cat="servicos"]',
               tipo: 'pointer',
               espera: 12, processamento: 441, desenho: 23 }

441 ms em processamento. Não era script de terceiro nem desenho: era o meu próprio handler.

Encontrar a linha

Com o alvo conhecido, o painel Performance do Chrome fecha o diagnóstico. Grave com CPU 4x slowdown ligado — sem isso, um MacBook mascara o que um celular médio sente.

O código era este:

src/components/Lancamentos.tsx
'use client';
export function Lancamentos({ linhas }: { linhas: Lancamento[] }) {
  const [categoria, setCategoria] = useState('todas');

  // Roda a cada render. 1.200 linhas × normalização de string × ordenação.
  const visiveis = linhas
    .filter((l) => categoria === 'todas' || normalizar(l.categoria) === categoria)
    .sort((a, b) => b.valor - a.valor);

  return (
    <>
      <Filtros onSelect={setCategoria} />
      {/* 1.200 linhas montadas de uma vez, todas no DOM */}
      {visiveis.map((l) => <Linha key={l.id} {...l} />)}
    </>
  );
}

Dois problemas somados: cálculo caro no caminho da renderização e 1.200 nós de DOM sendo reconciliados de uma vez. O clique disparava os dois de forma síncrona, e o navegador só voltava a responder quando tudo terminava.

A solução

  1. Separar o que é urgente do que pode esperar.

    O React 19 permite marcar uma atualização como não urgente. O chip muda de cor imediatamente; a lista se atualiza em seguida, sem bloquear.

    src/components/Lancamentos.tsx
    'use client';
    import { useState, useDeferredValue, useMemo } from 'react';
    
    export function Lancamentos({ linhas }: { linhas: Lancamento[] }) {
      // Estado urgente: reflete o clique na hora.
      const [categoria, setCategoria] = useState('todas');
      // Cópia atrasada: a lista pesada usa esta, e o React pode interrompê-la
      // se outra interação chegar no meio.
      const categoriaAdiada = useDeferredValue(categoria);
    
      const visiveis = useMemo(
        () => filtrarEOrdenar(linhas, categoriaAdiada),
        [linhas, categoriaAdiada],
      );
    
      return (
        <>
          <Filtros selecionada={categoria} onSelect={setCategoria} />
          <Lista itens={visiveis} desatualizada={categoria !== categoriaAdiada} />
        </>
      );
    }

    O useMemo sozinho não resolveria: ele evita recalcular quando nada mudou, mas o clique muda a categoria, então o cálculo aconteceria assim mesmo. É a combinação com useDeferredValue que tira o trabalho do caminho crítico.

  2. Normalizar uma vez, não a cada filtro.

    Metade do custo era normalizar() rodando 1.200 vezes por clique sobre dados que nunca mudam. Isso pertence ao servidor:

    src/lib/lancamentos.ts
    import 'server-only';
    
    export async function buscarLancamentos() {
      const linhas = await prisma.lancamento.findMany({ orderBy: { valor: 'desc' } });
      // Chave normalizada calculada uma vez, no servidor, e enviada pronta.
      return linhas.map((l) => ({ ...l, catKey: normalizar(l.categoria) }));
    }

    O filtro no cliente vira uma comparação de strings — barata o suficiente para nem aparecer no perfil.

  3. Parar de montar mil linhas de uma vez.

    O DOM tem custo mesmo sem JavaScript. Duas saídas, em ordem de esforço:

    A mais barata é CSS puro, que faz o navegador pular o layout do que está fora da tela:

    src/styles/global.css
    .linha-lancamento {
      content-visibility: auto;
      /* Altura estimada: sem ela a barra de rolagem pula enquanto o usuário rola. */
      contain-intrinsic-size: auto 56px;
    }

    Quando a lista passa de alguns milhares de itens, virtualização é o caminho — só os itens visíveis existem no DOM.

  4. Ceder a thread em laços longos.

    Quando não dá para eliminar o trabalho, dá para fatiá-lo. scheduler.yield() devolve o controle ao navegador e retoma logo depois, sem ir para o fim da fila:

    src/lib/processar-em-fatias.ts
    export async function processarEmFatias<T>(itens: T[], fn: (item: T) => void) {
      const inicio = performance.now();
      for (const item of itens) {
        fn(item);
        // A cada 50 ms, devolve a vez. É o limite acima do qual o navegador
        // classifica o bloco como long task.
        if (performance.now() - inicio > 50) {
          await ('scheduler' in window && 'yield' in scheduler
            ? scheduler.yield()
            : new Promise((r) => setTimeout(r, 0)));
        }
      }
    }
  5. Tirar terceiros do caminho do clique.

    Se o inputDelay for a fase dominante, o problema não é seu handler — é o que estava ocupando a thread quando o clique chegou. Scripts de análise, chat e anúncios devem carregar depois da interatividade:

    src/app/layout.tsx
    import Script from 'next/script';
    
    // lazyOnload: só depois de a página ficar ociosa.
    <Script src="https://exemplo.com/chat.js" strategy="lazyOnload" />

Como confirmar que resolveu

A instrumentação, de novo, no mesmo clique:

console do navegador
[INP] 88 ms { alvo: 'button.chip[data-cat="servicos"]',
              tipo: 'pointer',
              espera: 9, processamento: 54, desenho: 25 }

Nenhuma long task na gravação. No painel Performance com CPU 4x, o clique não deve produzir bloco vermelho. O limite é 50 ms de trabalho contínuo.

Teste em celular real, não só no emulador. A emulação de CPU do DevTools é uma aproximação. Um aparelho Android intermediário conectado por depuração remota conta a verdade.

Espere os dados de campo. O CrUX trabalha com janela de 28 dias, então o Search Console só reflete a correção semanas depois. Não conclua que não funcionou porque o número não mudou no dia seguinte.

Armadilhas que sobram depois disso

useDeferredValue não deixa o cálculo mais rápido. Ele o torna interrompível. Se o trabalho for absurdo, a lista simplesmente demora — só que sem travar o resto da interface. Reduzir o trabalho continua sendo o passo principal.

Hidratação pesada inflaciona o inputDelay dos primeiros cliques. Se o usuário interage enquanto a página ainda hidrata, o clique espera. Isso liga o INP diretamente ao tamanho do pacote de JavaScript — dois problemas que parecem distintos e são o mesmo.

content-visibility sem contain-intrinsic-size estraga a rolagem. Sem a altura estimada, o navegador recalcula o tamanho do documento conforme os itens entram na tela, e a barra de rolagem pula.

Medir só na home esconde o problema. O INP é agregado por página. Formulários e tabelas costumam ser muito piores que a página inicial, e é neles que o usuário passa tempo.

Continue por aqui