INP no Next.js: como diagnosticar e corrigir
Publicado em
O INP (Interaction to Next Paint) mede o tempo entre uma interação do usuário e o próximo frame pintado na tela; um bom valor é ≤200 ms no p75 de dados de campo. No Next.js, os estouros vêm tipicamente de hidratação concorrendo com o primeiro clique e de handlers longos ocupando a main thread. E há uma armadilha específica desta métrica: o Lighthouse não mede INP. Quem audita só em lab está cego para ela — este artigo mostra como coletar o dado de campo próprio e o que corrigir depois.
O que é INP e por que o Lighthouse não mostra
O INP substituiu o FID como Core Web Vital em 12 de março de 2024. A diferença é de escopo: o FID media apenas o input delay da primeira interação da visita — o tempo até o navegador começar a responder. O INP olha todas as interações da visita e mede o ciclo completo, incluindo o processamento do handler e a pintura do frame resultante. É uma métrica mais dura, e é por isso que ela pega problemas que o FID deixava passar.
Os thresholds de campo, no p75:
| Faixa | Valor no p75 |
|---|---|
| Bom | ≤200 ms |
| Precisa melhorar | 200–500 ms |
| Ruim | >500 ms |
Agora o ponto que muda a forma de trabalhar: não existe métrica de INP no Lighthouse. Não é omissão da interface — é impossibilidade conceitual. O INP exige interação real de usuário real, e o Lighthouse roda um ambiente de lab onde ninguém clica em nada. O que a auditoria de lab oferece no lugar é o TBT (Total Blocking Time), um proxy: ele soma o tempo em que a main thread ficou bloqueada durante o carregamento. Correlaciona com INP, mas não é INP.
O budget de Lighthouse CI deste site usa exatamente essa lógica — TBT ≤200 ms como proxy de lab, checado em todo pull request. É o melhor que o lab consegue dar, e vale a pena manter. Mas é um proxy, e proxy não substitui medição.
Isso cria uma cegueira dupla para sites pequenos. Sem interação em lab, não há INP no Lighthouse. E abaixo do threshold de tráfego do CrUX, também não há INP no PageSpeed Insights nem no Search Console — que é o caso deste site hoje. A saída é uma só: medir o próprio INP em campo.
Os 3 subpartes de toda interação
Antes de medir, é preciso saber o que se está medindo. Toda interação se divide em três fases, e a correção certa depende de qual delas está estourando.
O INP é a soma das três fases:
| Fase | O que acontece | Causa típica de estouro |
|---|---|---|
| Input delay | Tempo entre o clique e o início do handler | Main thread ocupada — hidratação, script de terceiro, long task |
| Processing duration | Execução do handler e do re-render | Handler longo, re-render síncrono caro, layout forçado |
| Presentation delay | Tempo até o navegador pintar o frame | DOM grande, estilos caros, lista longa sem virtualização |
Essa divisão não é acadêmica: ela é o que a attribution do web-vitals reporta, e é o que permite corrigir a causa certa em vez de chutar.
Como este site mede o próprio INP
Sem CrUX, a alternativa é RUM próprio. O código abaixo é o que está em produção em src/lib/rum.ts agora — importando de web-vitals/attribution (v5) e mandando um evento único web_vitals para o dataLayer, via um helper tipado.
// src/lib/rum.ts
export function reportINP(metric: INPMetricWithAttribution): void {
const { attribution } = metric
pushEvent({
event: 'web_vitals',
metric_name: 'INP',
metric_id: metric.id,
metric_value: Math.round(metric.value),
metric_rating: metric.rating,
// interactionTarget vazio = elemento removido do DOM após a interação
inp_element: (attribution.interactionTarget || '(unknown)').slice(0, MAX_SELECTOR_LENGTH),
inp_interaction_type: attribution.interactionType,
inp_load_state: attribution.loadState,
inp_input_delay: Math.round(attribution.inputDelay),
inp_processing_duration: Math.round(attribution.processingDuration),
inp_presentation_delay: Math.round(attribution.presentationDelay),
})
}A inicialização é trivial, com guarda para SSR e para chamada dupla:
// src/lib/rum.ts
export function initRum(): void {
if (started || typeof window === 'undefined') return
started = true
onLCP(reportLCP)
onINP(reportINP)
}Três detalhes desse código que não são óbvios e que custaram tempo:
MAX_SELECTOR_LENGTH = 100. O GA4 trunca parâmetros de evento em 100 caracteres. Sem oslice, seletores longos chegam cortados de forma silenciosa no relatório — melhor cortar de propósito e saber disso.interactionTargetpode vir vazio. Quando o elemento sai do DOM depois da interação (um modal que fecha, um item de lista que some), a attribution não consegue mais resolver o seletor. Daí o fallback'(unknown)'— sem ele, o breakdown por elemento fica com buracos.- O INP reporta de novo. Quando a página volta de
hidden, a biblioteca pode emitir um valor pior com o mesmometric_id. Qualquer análise que some ou conte eventos vai inflar o número. A leitura correta é o máximo pormetric_id.
Do dataLayer o caminho segue: GTM lê seis Data Layer Variables inp_* mapeadas na tag, e o GA4 recebe três dimensões custom (elemento, tipo de interação, load state) e três métricas custom em milissegundos, event-scoped. O pipeline está live em produção desde 22/07/2026.
Uma nota sobre LGPD, já que o assunto é analytics: o site roda Consent Mode v2 em modo advanced. Com consentimento negado, o hit vira ping sem cookie — o dado de performance não vira rastreamento de usuário.
O que o campo já mostrou (e o que ainda não)
Transparência de laboratório: o pipeline de INP entrou em produção no dia da publicação deste artigo. Os primeiros eventos reais já chegaram ao GA4 com os seis parâmetros preenchidos — mas um punhado de interações não é estatística. Percentil calculado sobre meia dúzia de eventos é ruído com aparência de precisão, e este site não publica número que não sustenta.
O compromisso fica registrado: quando houver uma janela de 28 dias de coleta — a mesma que o CrUX usa —, esta seção ganha os percentis reais por elemento e por subparte, com o dateModified do artigo atualizado de verdade. Até lá, o que este artigo entrega é o que já é verificável hoje: o código, o pipeline e o método.
Diagnóstico: DevTools e Long Animation Frames
O RUM diz que o INP está alto e em qual elemento. Para saber por quê, o caminho é o Performance panel do DevTools: grave uma sessão, interaja com o elemento que o RUM apontou e procure as long tasks em volta da interação.
O que torna esse diagnóstico muito mais direto hoje é a Long Animation Frames API (long-animation-frame), disponível em Chrome e Edge. É ela que a attribution do web-vitals usa para apontar qual script foi o culpado por segurar o frame — em vez de "alguma coisa bloqueou a main thread", você recebe o script.
Combinação que funciona bem: o campo dá o elemento e a fase; o Performance panel dá o script e a stack. Sem o primeiro, você otimiza o que não dói.
Catálogo de correções no Next.js
Ordenado da correção mais barata para a mais cara.
1. Menos JavaScript hidratando
A correção com melhor relação custo-benefício no App Router é estrutural: Server Components por padrão, 'use client' só na folha da árvore. Cada componente que hidrata é main thread ocupada no pior momento possível — durante o carregamento, que é justamente quando o usuário faz a primeira interação.
Para ilhas pesadas abaixo da dobra, next/dynamic adia o custo. Nota honesta: este site ainda não precisou de dynamic import — o bundle é pequeno o suficiente para não justificar. Digo isso porque a alternativa seria fingir uma otimização que não existe aqui.
2. Quebrar long tasks com scheduler.yield()
Um handler que roda 300 ms segura o frame por 300 ms. A solução é ceder a main thread no meio do trabalho — pintar o feedback visual primeiro, fazer o resto depois.
// utils/yield-to-main.ts
export function yieldToMain(): Promise<void> {
if (typeof globalThis.scheduler?.yield === 'function') {
return globalThis.scheduler.yield()
}
return new Promise((resolve) => setTimeout(resolve, 0))
}O fallback não é opcional. O scheduler.yield() está disponível em Chrome 129 e Edge 129 (set/2024) e Firefox 142 (ago/2025), e segue sem suporte no Safari (verificado em 22/07/2026) — ou seja, não é Baseline. Em Safari o setTimeout(0) cede a thread do mesmo jeito; o que se perde é a continuação priorizada.
3. useTransition e useDeferredValue
Quando o problema é o re-render (processing duration), o React 19 oferece a marcação de urgência: useTransition para envolver a atualização cara e useDeferredValue para adiar um valor derivado. O input continua respondendo enquanto a lista pesada recalcula.
4. Evitar layout síncrono forçado
O padrão que causa isso é ler layout (offsetHeight, getBoundingClientRect), escrever no DOM e ler de novo no mesmo handler — cada leitura depois de uma escrita força o navegador a recalcular tudo. A correção é agrupar: todas as leituras primeiro, todas as escritas depois.
5. Presentation delay: DOM e estilos
Se a attribution aponta inp_presentation_delay alto, o handler já terminou e o gargalo é pintar. Aí o alvo é o tamanho do DOM e o custo dos estilos: virtualizar listas longas, e content-visibility em blocos fora da viewport onde couber.
6. Terceiros competem pela mesma thread
Todo script de tag e analytics disputa a main thread com a sua UI — inclusive o GTM que alimenta a medição descrita acima. Vale ser honesto sobre isso: o pipeline de RUM deste site tem custo. Para medir esse custo, grave o Performance panel duas vezes, uma com o request do GTM bloqueado e outra sem, e compare as long tasks.
7. Input delay quase nunca é o problema em si
Se inp_input_delay está alto, o handler nem começou — a main thread estava ocupada com outra coisa. É sintoma dos itens 1 e 2, não uma categoria separada. O campo inp_load_state costuma entregar o caso mais comum: interação durante a hidratação.
O que NÃO fazer
A tentação óbvia é remover interatividade para pontuar melhor. Tirar o filtro que o usuário usa, cortar o JavaScript que faz o produto funcionar, atrasar o carregamento de coisas que a pessoa precisa. Isso melhora o número e piora o site.
O INP existe para medir a sensação de resposta, não para ser vencido. Um site sem interação tem INP perfeito e serventia zero.
Como verificar
Depois de aplicar qualquer correção acima:
- No GA4, monte uma Exploration com
metric_name = INP, agregando pelo máximo pormetric_id(não pela soma — ver a armadilha do reporte duplo acima), com breakdown porinp_elemente pelas três subpartes. Compare a janela antes e depois do deploy. - No DevTools, repita a gravação do Performance panel na mesma interação e confira se a long task que você atacou saiu do rastro.
- No Lighthouse, o que vai se mover é o TBT, não o INP — use como sinal indireto e não confunda um com o outro.
- No CrUX, se e quando o site cruzar o threshold de tráfego, lembre que a janela é de 28 dias: dado de campo demora a refletir a correção.
O ponto de ordem é sempre o mesmo: correção sem medição de campo é chute com sintaxe bonita.
Este artigo é o caminho 6 do guia de SEO técnico para Next.js. Se o problema for de carregamento e não de interação, o ponto de partida é como melhorar o LCP no Next.js — foi lá, aliás, que este pipeline de RUM nasceu. E para validar o FAQPage deste próprio artigo, o gerador de JSON-LD resolve.