INP (Interaction to Next Paint) Dicas para ajudar a melhorar essa métrica do Core Web Vitals
Michel Araujo
Posted on April 15, 2024
Esse post tem como objetivo mostrar algumas técnicas simples que podem ser usadas em aplicações reais e ajudar você a da os primeiros passos na melhora do INP, os exemplos aqui em termos de codificação e funcionalidades são realmente simples mas o ideal é fazer o exercício de pegar o que achar interessante aqui entender mais a fundo e abstrair para o seu cenário em questão.
O que o INP tenta resolver?
Primeiro é importante de fato entender o que é a métrica Interaction to Next Paint (INP), qual problema ela tenta resolver (evidenciar) e se quiser entender um pouco mais do histórico seria bom pesquisar porque ela substituiu o FID como métrica do Core Web Vitals.
Dando um resumo inicial o INP tenta medir a responsividade que uma página web tem metrificando o tempo que leva para o navegador renderizar algo na tela depois de alguma interação do usuário, essa interação pode ser click, tab, eventos de teclado, mouse e etc, com base nesse valor (em MS) podemos medir seguindo as definições do Google se um interação tem uma boa, média ou pobre responsividade (INP).
Para a medição do Core Web Vitals em si que é o valor mostrado na página do pagespeed, por exemplo:
Cada interação na sua página vai gerar um valor em MS que conta para essa métrica, usuários que usam o Chrome o CrUX (Chrome User Experience Report) vai pegar sempre o maior valor para contar como a métrica INP daquela sessão, exemplo:
Se o usuário clicar em dois botões na página e essas interações ficarem em torno de 30ms e depois na mesma sessão o usuário clicar em um link e a interação lever 200ms é os 200 que conta, que é enviado pelo CrUX.
Tem algumas regrinhas para remover outliers e gera um número baseado no 75 percentil com base nos acessos de outros usuários, mas em resumo é mais ou menos assim que funciona.
Para entender melhor como esses dados são coletados aconcelho da uma olhada nesse post: https://web.dev/articles/vitals#measure-report
Ponto importante: Vale ressaltar aqui que essas métricas do Web Vitals não são feitas de forma aleatórias pelo Google, nem para “ferrar” os donos de site mas é um meio de metrificar, evidenciar problemas nas páginas web que impactam diretamente a experiência do usuário e que muitas vezes passam despercebidas, então sempre tenha em mente que as métricas do Web Vitals no fim de dia é para ajudar na UX da sua página.
Recomendo entender um pouco como o browser funciona
Para entender mais facilmente como identificar os pontos de gargalos e entender como as técnicas mostradas aqui ajudam é recomendado ter uma noção básica de como o navegador funciona, eu gosto bastante desse vídeo ele dá uma boa introdução sobre o event loop e como o JS é executado na página:
Tem também a transcrição do vídeo em formato de post:
https://www.andreaverlicchi.eu/blog/jake-archibald-in-the-loop-jsconf-asia-talk-transposed/
O problema
Beleza, agora temos uma noção um pouco melhor do que é o INP e de como esses dados são coletados, agora vamos entender mais a fundo o problema que essa métrica tenta evidenciar. Para exemplificar de uma forma mais didática eu criei uma página de exemplo que tem apenas uma interação (click em um botão para mostrar uma imagem) mas essa interação está com uma podre responsividade (problema de INP).
Obs: O objetivo aqui não é focar no código fonte ou boas práticas de programação mas sim nas técnicas para melhora do INP.
A página de exemplo está em Next JS 14.1.4 e React 18, a estrutura inicial foi criada usando os comando CLI do próprio Next JS, para esse post foi escolhida essas tecnologias apenas pela facilidade do setup inicial.
Todos os testes realizados são simulando um Samsung Galaxy S8 com 4x CPU slowdown pelo DevTools.
O código do exemplo pode ser encontrado aqui.
O exemplo
Na nossa página de exemplo temos um botão de click que mostra uma imagem de gato (claro!) e por baixo dos panos é executado algumas outras tasks JS que no mundo real pode ser comparada com acesso a uma API, processar alguma regra de negócio, envia uma tag de Analytics e etc.
Inicialmente ao clicar no botão de “So Something” temos um INP de 2 a 3 segundos!!
Lembrando que o ideal segundo a documentação do Google é ficar abaixo de 200 ms.
Exemplo em video:
Beleza.. Sabemos que temos um problema, agora vamos começar a entender a causa e como resolver. Para mostrar no console os MS de cada interação na página igual mostrado no vídeo estamos usando esse código:
(()=>{let o=globalThis;return void 0===o.winp&&(o.winp=0,new PerformanceObserver(n=>{for(let e of n.getEntries()){if(!e.interactionId)continue;o.winp=Math.max(e.duration,o.winp);let r=o=>o<=200?"color: green":o<=500?"color: yellow":"color: red";console.log(`%c[Interaction: ${e.name.padEnd(12)}] %cDuration: %c${e.duration}`,"color: grey; font-family: Consolas,monospace","",r(e.duration))}}).observe({type:"event",durationThreshold:0,buffered:!0})),o.winp})();
performance.interactionCount;
Basta colar no console do navegador e começar a interagir na página, esse código mostra os MS de cada interação e seu tipo (click, keyup e etc).
Sabendo de tudo isso a primeira coisa que vamos fazer é gravar essas interações usando a aba de performance do DevTools, no momento temos o seguinte resultado:
Cada coluna desta é uma task JS que foi disparada pela iteração feita no botão, se observarmos com atenção podemos ver que cada task JS desta está relacionada a um evento de click, se passar o mouse em cima da task vamos ver o tempo que a mesma levou para executar, vale lembrar que esse é o tempo que o event loop fica preso executando essa task até poder fazer outra coisa como entrar no passo de render (pintar algo na tela). Um pouco mais acima temos a parte das interações “Interactions” essa parte mostra o tempo que uma interação demorou desde do seu início até seu fim e quais task JS afetou essa interação ou seja, por aqui também podemos medir o tempo do INP e de quebra entender quais tasks são responsáveis pelo delay na interação, veja esse exemplo, essa interação demorou 2.55 segundos (tempo do INP).
Nesse exemplo, olhando para esse relatório gravado pelo DevTools já dá para saber que apenas uma task JS está bloqueando toda a main thread, mas o que ela faz?
Olhando com mais atenção a Call Tree podemos ver que lá no final temos uma função de onClick sendo executada e depois 3 tasks grandes de uns 900 ms cada sendo executadas de forma sequencial (indício de problema).
Obs o INP é calculado no próximo render do browser que na gravação do DevTools é a parte verde que geralmente é o “Paint” ou “Commit” que significa que o browser renderizou algo na tela, no nosso exemplo se dermos um zoom nessa gravação podemos achar o Commit lá depois de toda essa task grande JS ser executada, esse é um problema de fato para o INP
Se você tiver acesso ao código fonte da aplicação que estiver analisando e se for em frameworks como o Next JS geralmente tem como você executar em modo de desenvolvimento, fazendo essa mesma gravação no DevTools em mode de desenvolvimento pode ser que fique mais claro o nome de cada função que está sendo executada e facilite na hora de achar o problema, veja a próxima imagem, vale lembrar também que testando a aplicação em modo de desenvolvimento pode ser que a performance fique pior ainda e o resultado mais distante de produção.
O que sabemos até agora..
Tem uma função de onClick que realiza 3 tarefas pesadas dentro dela e compromete o next paint do browser consequentemente o INP.
Olhando para o código da nossa função temos isso:
const doSomething = () => {
const rTask1 = task1();
const rTask2 = task2();
const rTask3 = task3();
// Change state
setShowImage(!showImage);
};
A função doSomething é a nossa função de handler do click e de cara podemos ver as 3 funções que estão sendo chamadas de forma sequencial seguido de um setState.
Tendo esse pequeno código em mente vou da a principal dica para melhorar o INP:
Sempre priorize o render (o feedback para o usuário) depois de alguma interação.
Todas as técnicas mostradas a seguir foram pensadas seguindo essa dica.
A técnica do yielding
A primeira vez que eu vi essa técnica foi lento esse post que por sinal recomendo muito.
Tentando explicar de forma resumida, essa técnica consiste em quebrar task JS grandes em menores, se lembrarmos um pouco de como o browser funciona o pipeline de render é executado pelo event loop entre a execução de task JS, o event loop pode executar centenas de task JS antes de decidir fazer um render, quebrando tasks grandes em menores podemos dizer então que estamos dando mais oportunidade para o browser fazer o render de algo na tela, lembrando da dica de sempre priorizar o render já dá para perceber como essa técnica ajuda, vamos ter tasks menores e o browser pode fazer o render mais vezes.
Para implementar essa técnica basta apenas criar uma função assim:
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
E chamar ela onde você tem oportunidade de quebrar a task em execuções menores.
Para maiores informações sobre essa técnica vale dá uma olhada aqui
A primeira alteração que vamos fazer no código então é a seguinte, vamos chamar a função de yieldToMain() depois de cada task que temos na função doSomething, o código ficou assim:
Gravando novamente as interações já dá para notar que não temos mais aquela task gigante mas sim apenas 3 task grande =D e monitorando as interações da para perceber uma pequena melhoria o que antes estava em torno de 3 segundos agora foi para 1 segundo, ainda é muito ruim mas vamos continuar as alterações…
Continuando as melhorias pensando na dica de sempre priorizar o render vamos supor que essas 3 taks sejam coisas de background e o setState não precise esperar o retorno dessas função para ser executado então podemos priorizar totalmente o setState, o código fica assim:
const doSomething = async () => {
//Change state
setShowImage(!showImage);
await yieldToMain();
const rTask1 = task1();
await yieldToMain();
const rTask2 = task2();
await yieldToMain();
const rTask3 = task3(); });
Fazendo isso podemos ver mais alguma melhora no resultado dos testes, olhando novamente o resultado das interações e as gravações temos o seguinte resultado:
Aqui vem um pouco muito importante, mesmo com esse resultado bom para o INP não quer dizer que já temos a melhor UX, o browser aqui pode ter considerado o change do botão, borda, pequena mudança de cor na hora do click mas a imagem em si que queremos mostrar ainda vai ter o delay de algum tempo, temos que ficar espertos com isso se nosso objetivo é melhorar a UX como um todo.
O ponto chave dessa alteração é esse
Agora temos uma task menor que provavelmente já está processando o setState (linha verde no report gravado, próxima imagem) depois tem uma quebra com o yieldToMain que foi acrescentado depois do setState no código (repare no código acima) dando oportunidade para o browser fazer o render (seta vermelha na imagem).
Para esse caso as interações já estariam aprovadas < 200 ms, mas como no mundo real nem tudo são flores ainda temos alguns casos que podem degradar nosso INP. O primeiro deles é se nosso render (setState) depender do retorno de alguma função dessa, nesse cenário a solução é até simples, podemos colocar um loading para fazer o browser executar o next render depois de uma interação o mais rápido possível dando o feedback para o usuário de que algo está sendo processado, fazendo essa modificação no nosso código fica assim:
Nesse caso teoricamente não é para ter grandes modificações nas medições de performance, tudo é para continuar verde.
Note que o setState que faz a alteração da imagem de fato voltou para depois que as task são executadas.
Resultado em video:
Cenario do usuarios impacientes
Novamente o mundo real é mais cruel do que a gente testando em laboratório, vamos supor que nossos usuários são impacientes e ficam clicando no botão antes que de tempo das task JS realmente terminar de fato, nesse cenário ainda vamos ter um problema de INP sério, veja a reprodução desse cenário:
Nesse vídeo estou clicando no botão de doSomething várias vezes
Lembrando que não foi feita mais nem uma alteração de código depois dos últimos testes o que mudou apenas foi o comportamento do usuário (nesse caso a forma que estamos testando)
Nesse cenário o que está acontecendo é a próxima interação que é feita está iniciando enquanto o browser ainda não terminou de processar umas das task grandes, podemos ver isso na gravação:
Perceba as interações iniciando no meio da task.
PAUSA NOS CENÁRIOS - Entendendo um pouco mais do INP
Para o cálculo do valor total do INP é considerado 3 sub métricas que são:
- Input Delay
- Processing time
- Presentation delay
Juntando o valor em MS dessas 3 sub métricas temos o valor total do INP, exemplo:
- Input Delay - 30 ms
- Processing time - 50 ms
- Presentation delay - 10 ms
Valor do INP é de 90 MS
Prestar atenção nessas sub métricas pode nos ajudar a identificar onde está o gargalo de algum ponto.
Voltando para o exemplo..
Podemos ver que o que mais está demorando é o presentation delay ou seja, a task está pronto para o browser renderizar mas o browser estava ocupado executando as outras task e levou 721 ms até achar um tempo para renderizar.
Em outras casos é o input delay que demora mais ou seja o browser está ocupado processando alguma task que não conseguiu nem iniciar a nova task referente a interação, por todos esses motivos que é importante ter em mente sempre tentar ter tasks JS menores possível.
Para melhorar esse cenário podemos fazer algumas alterações no código e usar algumas outras técnicas como o requestAnimatedFrame para sinalizar para o browser que no próximo frame teremos alguma renderização na tela e que aquela task JS vai ter alguma renderização.
doc
Podemos também deixar as task async para as mesmas serem executadas em paralelo reduzindo o tempo total de processamento e evitando o “choque” de entrar uma nova task de interação enquanto ainda tem coisa processando.
Com todas essas alterações chegamos na melhor otimização possível até o momento, o código ficou assim:
const doSomething = async () => {
// Show loading
window.requestAnimationFrame(() => {
setPageState({ ...pageState, loading: true });
});
await yieldToMain();
const promiseTask1 = task1();
const promiseTask2 = task2();
const promiseTask3 = task3();
await promiseTask1;
await yieldToMain();
await promiseTask2;
await yieldToMain();
await promiseTask3;
await yieldToMain();
// Hide loading and show image
window.requestAnimationFrame(() => {
setPageState({ showImage: !pageState.showImage, loading: false });
});
};
Execução ficou assim:
A imagem acima foi feita simulando todos os cenários de um simples clique e de ficar clicando múltiplas vezes, sendo assim podemos observar que apesar de termos uma boa melhora do início do post que a média ficava em torno de 2 a 3 segundo ainda temos alguns gargalos para melhorar.
Até o momento a gente só trabalhou no gerenciamento das tasks e ajudando o browser a entender que algo deve ser renderizado na tela, as funções que estão lenta de propósito até o momento ainda continuam lentas (não foram alteradas), o objetivo até aqui foi mostrar que mesmo que temos funções difíceis de otimizar apenas gerenciando bem as task podemos conseguir melhorar consideravelmente a média do INP mas para melhorar de fato temos que foca em sempre fazer as tasks menores possíveis, para exemplificar isso vamos mexer agora nas tasks de exemplos que estavam lenta de propósito (task1, task2, task3), vamos criar um novo cenário em que temos mais tasks porém com tempo de execução menor, o código vai fica assim:
const doSomething = async () => {
window.requestAnimationFrame(() => {
setPageState({ ...pageState, loading: true });
});
const tasks = [];
for (let i = 0; i < 10; i++) {
const rTaskPromise = task1();
tasks.push(rTaskPromise);
await yieldToMain();
}
await Promise.all(tasks);
window.requestAnimationFrame(() => {
setPageState({ showImage: !pageState.showImage, loading: false });
});
};
const manageImage = () => {
if (pageState.showImage) {
return <img
src="http://localhost:3000/cat_img.webp"
width={612}
height={408}
alt="Image of the a Cat"
decoding="async"
/>
}
if (pageState.loading) {
return <img
src="http://localhost:3000/loading.webp"
width={300}
height={300}
alt="Image of the loading"
decoding="async"
/>
}
};
return (
<>
<button onClick={doSomething} title="Show or hide image button">Do Something</button>
<br />
{manageImage()}
</>
);
Coloquei o código mais completo dessa vez para vocês terem uma noção do que está acontecendo no render também.
O resultado do report ficou assim:
Com esse report podemos observar:
- O INP foi super rápido, ficou nos 29 ms ou seja está verde.
- O loading está sendo mostrado o mais rápido possível.
- Agora temos várias tasks pequenas (simulando a quebra daquelas task grande que teríamos antes), assim se o browser precisar tem mais oportunidade para fazer um render no meio dessas tasks.
- Por fim temos o render da imagem em si.
Se a gente simular um cenário de stress agora (vários clicks seguidos) vamos observar que tem bem menos engasgo do que antes, isso é onde as tasks menores ajudam!
Bônus - mostrar e sumir com um elemento
Minha ideia era parar o post por aqui, mas fazendo esses testes com esse exemplo simples apareceu um cenário muito interessante que dependendo da sua funcionalidade pode surgir na vida real também.
Se você já não reparou observe no último report que temos um step de commit que está tomando bastante tempo, isso pode ser um problema principalmente para o cenário de stress, como em task JS uma nova interação pode surgir e o browser está executando essa task de commit longa, exemplo:
Como você já deve ter entendido isso é ruim para o INP.
Nesse meu exemplo simples isso aconteceu pelo modo que estou fazendo o elemento de imagem (tag img) ser removida e incluída no DOM toda hora que interagimos com o botão, isso força o browser fazer todo o processo de layout, paint, render em toda interação, para melhorar isso em vez de ficar adicionando e removendo o elemento do DOM vou trabalhar com opacidade e animação, assim o browser só vai executar os passos realmente necessários para a animação mas o elemento em si vai continuar na árvore do DOM e Layout, vamos ver como fica:
O report agora ficou assim, podemos notar que não tem mais aquela task de commit grande, tudo parece fluir muito melhor agora com todas tasks pequenas!
Conclusão
Espero que algumas dessas dicas ajude alguém nas suas otimizações na vida real, a ideia do post foi realmente passar o contexto e ajudar nos primeiros passos dessa jornada de otimização, como prática faça seus próprios testes, tente criar exemplo usando as dicas desse post, depois pode comentar se o resultado foi parecido com os dos meus teste.
Acredito que nos próximos meses/ano vai ter muito mais coisa boa vindo ai que pode ajudar ainda mais, muita feature nova nos browser que hoje só funciona no Chrome (por isso nem citei aqui), WebAPIs que vão ajudar a usar menos JS na página e etc.
O código completo deste exemplo vai está nesse github
Posted on April 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.