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.
Ambiente testado
- Next.js 15.3
- React 19.1
- web-vitals 5
- Chrome DevTools
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
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.

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:
'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:
[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:
'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
-
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
useMemosozinho 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 comuseDeferredValueque tira o trabalho do caminho crítico. -
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.tsimport '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.
-
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.
-
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.tsexport 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))); } } } -
Tirar terceiros do caminho do clique.
Se o
inputDelayfor 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.tsximport 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:
[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
Performance Web
Streaming e Suspense: eliminando o waterfall que trava a página
Um await depois do outro num Server Component soma latências. Como paralelizar e, melhor ainda, entregar a página antes dos dados chegarem.
Performance Web
Bundle analyzer: as dependências que sempre estouram o JavaScript
Quando o peso está no pacote compartilhado, o problema não é uma rota — é uma biblioteca. As seis que mais aparecem e o que colocar no lugar.
Performance Web
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.