reactanimations

Core Web Vitals : ce que l'INP mesure vraiment, et comment scheduler.yield() sauve le thread principal

Core Web Vitals : ce que l'INP mesure vraiment, et comment scheduler.yield() sauve le thread principal

Dans l'article sur les micro-interactions, je montrais pourquoi transform et opacity s'animent avec fluidité même sur un téléphone modeste – car le navigateur les gère uniquement au stade de la composition, sur un thread GPU séparé, sans toucher à la mise en page. C'est toujours vrai, mais cela cache une hypothèse qu'il vaut la peine de dévoiler maintenant : pour que cette animation démarre ne serait-ce qu'une fois, le navigateur doit d'abord traiter l'événement de clic sur le thread principal – le même thread sur lequel s'exécute tout votre JavaScript. Si, au moment du clic, le thread principal est occupé à exécuter une longue tâche synchrone – filtrer un grand tableau, parser une réponse d'API, rendre un arbre de composants complexe – l'événement de clic ne sera pas traité une seconde avant que cette tâche ne se termine. L'animation elle-même peut être aussi bon marché que possible. Peu importe, si personne n'a encore eu l'occasion de la déclencher.

Interaction to Next Paint (INP) est la métrique Core Web Vitals qui mesure exactement cet écart – pas le temps jusqu'au premier octet, pas le temps de chargement de la page, mais le temps entre le moment où l'utilisateur appuie sur quelque chose et le moment où le navigateur peint réellement à l'écran l'effet de cette interaction. Elle a remplacé l'ancienne métrique FID (First Input Delay) en mars 2024 pour une raison importante : FID ne mesurait que la première interaction sur une page, donc une page pouvait obtenir un excellent score même si chaque clic suivant le premier saccadait pendant une seconde entière. L'INP prend en compte toutes les interactions sur tout le cycle de vie de la page et rapporte en pratique le pire cas répété – impossible de le « réparer » avec une bonne première impression.

Pourquoi le JavaScript ne peut pas être interrompu en cours de route

Le mécanisme clé, sans lequel l'INP n'a pas de sens : le JavaScript dans le navigateur fonctionne selon le modèle run-to-completion – une tâche, une fois lancée, s'exécute du début à la fin, sans interruption, indépendamment de ce que l'utilisateur essaie de faire entre-temps. Un clic pendant l'exécution d'une telle tâche n'est pas ignoré – il atterrit dans une file et attend que le thread se libère. Une tâche qui dure plus de 50 ms est formellement classée comme long task (Long Tasks API) – ce n'est pas un chiffre arbitraire, mais une approximation du seuil au-delà duquel l'utilisateur commence à ressentir subjectivement que quelque chose « bloque », dans un esprit proche du seuil de Doherty évoqué dans l'article sur les micro-interactions, mais mesuré ici côté coût de calcul, pas côté réseau.

L'INP se décompose en trois composantes, et les longues tâches gonflent le plus souvent la première :

  • Le délai d'entrée (input delay) – le temps entre le clic et le moment où le thread principal commence enfin à traiter l'événement. C'est précisément là qu'une longue tâche bloque tout.
  • Le temps de traitement (processing time) – combien de temps prend réellement l'exécution de votre gestionnaire d'événement.
  • Le délai de présentation (presentation delay) – le temps entre la fin du gestionnaire et le moment où l'image avec l'effet est réellement peinte.

Le yielding : comment rendre la main au navigateur en plein travail

Puisqu'une tâche ne peut pas être interrompue de l'extérieur, la seule issue est de l'interrompre volontairement de l'intérieur – découper un long travail synchrone en petits morceaux et rendre la main au navigateur entre chacun d'eux, pour qu'il ait le temps de traiter les entrées en attente et de peindre une image avant que vous ne repreniez le morceau de travail suivant.

javascript

async function processInChunks(items, processItem) {
const results = [];
for (let i = 0; i < items.length; i++) {
results.push(processItem(items[i]));
if (i % 50 === 0) {
if ("scheduler" in window && "yield" in scheduler) {
await scheduler.yield();
} else {
// Fallback : setTimeout(fn, 0), PAS Promise.resolve().then()
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}
return results;
}

Un détail facile à manquer et qui ruine réellement l'effet de cette technique : await Promise.resolve() ou queueMicrotask() ne rendent pas la main au navigateur. Ce sont des microtâches (microtasks) – la file de microtâches est entièrement vidée avant même que le navigateur envisage de traiter une entrée en attente ou de peindre une image. Si votre boucle asynchrone n'utilise que des microtâches pour « découper » le travail, du point de vue du navigateur, c'est toujours une seule tâche continue – simplement fragmentée en then() plutôt qu'en simples lignes de code. Pour réellement rendre la main, il vous faut une frontière de macrotâchesetTimeout, MessageChannel (plus rapide que setTimeout(0), car il évite le délai minimal imposé par les navigateurs aux timeouts imbriqués), ou le nouveau scheduler.yield() dédié de la Scheduler API, qui priorise en plus intelligemment la continuation par rapport aux autres tâches en attente, au lieu d'atterrir en fin de file comme setTimeout.

isInputPending() : ne rendez pas la main si personne ne l'attend

Rendre la main après chaque élément de la boucle a un coût – chaque passage dans la boucle d'événements est une charge. Si personne n'a rien cliqué à ce moment précis, interrompre le travail toutes les 50 itérations est une perte de temps pure, qui allonge le temps de traitement total sans le moindre bénéfice pour l'utilisateur. navigator.scheduling.isInputPending() résout cela en posant directement la question : y a-t-il réellement un événement d'entrée non traité en attente dans la file, avant de décider d'interrompre le travail.

javascript

function processQueue(tasks) {
while (tasks.length > 0) {
if (navigator.scheduling?.isInputPending()) {
break; // quelqu'un attend réellement – rends la main maintenant
}
doExpensiveWork(tasks.shift());
}
if (tasks.length > 0) {
setTimeout(() => processQueue(tasks), 0);
}
}

Cela inverse la logique par rapport au « toutes les 50 éléments » rigide : au lieu de deviner à l'avance combien de travail est « sûr » entre deux yields, vous demandez au navigateur en temps réel s'il y en a réellement besoin. Avec une file d'entrée vide, la boucle peut continuer sans interruption bien plus longtemps que 50 itérations – face à un vrai clic en cours de traitement, elle rendra la main immédiatement, exactement là où ça compte.

Pièges et bonnes pratiques

  • Les microtâches ne comptent pas comme un yield. C'est l'erreur la plus fréquente en tentant de « réparer » l'INP – le code a l'air asynchrone, il a un await, mais si derrière ce await se cache seulement un Promise.resolve(), le navigateur traite toujours l'ensemble comme une seule tâche ininterruptible.
  • scheduler.yield() a toujours besoin d'un fallback. Le support est limité principalement aux navigateurs basés sur Chromium – traitez-le comme une amélioration progressive avec setTimeout comme solution de repli sûre, exactement comme la Paint API dans l'article sur CSS Houdini.
  • Les longues tâches provenant de scripts tiers comptent aussi dans votre score INP. Analytics, publicités, widgets de chat – ils vivent sur le même thread principal et le bloquent tout autant que votre propre code. Un PerformanceObserver à l'écoute des entrées de type longtask les révélera toutes, quelle que soit leur origine – il vaut la peine de mesurer du trafic de production réel (RUM), pas seulement des tests locaux sur un environnement de développement vide et mis en cache.
  • L'INP mesure le pire cas répété sur tout le cycle de vie de la page, pas la première impression. Une interaction qui ne saccade qu'après dix minutes d'utilisation de l'application, une fois que beaucoup de données se sont accumulées côté client, compte toujours dans le score – tester uniquement les premiers clics sur une page fraîchement chargée sous-estime systématiquement le problème réel.

Conclusion

L'article sur les micro-interactions montrait comment faire en sorte que l'animation elle-même ne coûte rien au thread principal. L'INP montre l'autre moitié de la même équation : même l'animation la moins coûteuse du monde ne servira à rien si le thread principal est occupé par une longue tâche JavaScript ininterruptible au moment où l'utilisateur clique. Le modèle run-to-completion fait que le seul moyen d'obtenir de la réactivité est de découper volontairement son propre travail lourd en morceaux et de rendre la main au navigateur entre eux – par une vraie macrotâche, pas une microtâche, et idéalement quand isInputPending() confirme réellement que quelqu'un attend. Toute la chaîne de la performance perçue, du seuil de Doherty à l'INP, n'est aussi solide que son maillon le plus faible – et ce maillon s'avère de plus en plus souvent n'être ni le réseau ni le serveur, mais son propre JavaScript, resté trop longtemps indivis.