Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.

INP : la métrique qu'on a longtemps ignorée et qu'on prend maintenant au sérieux

  • perf

INP remplace FID dans les Core Web Vitals depuis mars 2024. Un an plus tard, voici ce qu'on a compris de cette métrique et comment on l'optimise réellement.

INP : la métrique qu'on a longtemps ignorée et qu'on prend maintenant au sérieux

INP, Interaction to Next Paint, est devenu un Core Web Vital en mars 2024, en remplacement de FID. On a mis plusieurs mois à vraiment le comprendre. Ce n'est pas une métrique de chargement. C'est une métrique de réactivité sur l'ensemble de la durée de vie de la page.

Ce que mesure INP exactement

FID mesurait le délai avant que le navigateur puisse commencer à traiter une interaction. Un seul évènement, la première interaction.

INP mesure la durée totale entre une interaction et le prochain affichage qui en résulte, et ce, pour toutes les interactions pendant la session. La valeur reportée est le 98e percentile de toutes les interactions mesurées.

Concrètement : si un utilisateur clique 50 fois sur votre page et que 49 clics sont traités en 50 ms mais un seul prend 800 ms, c'est ce 800 ms qui dégrade votre INP (si c'est dans les 2 % les plus lents de la session).

Le seuil Google :

  • Bon : ≤ 200 ms
  • À améliorer : 200–500 ms
  • Mauvais : > 500 ms

Pourquoi on l'a ignoré au début

FID était simple à passer : si le thread principal n'était pas bloqué au moment du premier clic, FID était bon. On optimisait le temps de chargement et FID suivait.

INP est différent. Une page peut charger vite, afficher un bon LCP, avoir un bon FID, et avoir un INP dégradé parce qu'un composant React exécute trop de travail lors d'une interaction utilisateur qui survient 30 secondes après le chargement.

On a découvert nos vrais problèmes INP uniquement après avoir regardé les données CrUX de vrais utilisateurs, pas nos tests lab.

Comment mesurer INP correctement

En lab (DevTools Performance) :

// Injecter dans la console pour mesurer INP pendant la session
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'event') {
      const duration = entry.duration;
      if (duration > 100) {
        console.warn(`Interaction lente détectée : ${duration.toFixed(0)}ms`, {
          type: entry.name,
          target: entry.target?.tagName,
        });
      }
    }
  }
});

observer.observe({ type: 'event', buffered: true, durationThreshold: 0 });

En field data : Google Search Console expose désormais l'INP par page. C'est la source la plus fiable parce qu'elle reflète les vrais appareils de vos utilisateurs, notamment les appareils Android mid-range, qui sont beaucoup plus lents que le MacBook sur lequel on développe.

Le principal coupable : les long tasks React

Un long task est une tâche JavaScript qui dure plus de 50 ms sur le thread principal. Pendant ce temps, le navigateur ne peut pas traiter les interactions.

Sur nos projets Next.js, les long tasks les plus fréquentes venaient de deux sources :

1. Les re-renders React coûteux déclenchés par des interactions.

Un filtre sur une liste de 500 éléments, par exemple. Chaque frappe dans un champ de recherche déclenchait un re-render complet du composant liste.

// Problématique : re-render synchrone sur chaque keystroke
function ProductList({ products }: { products: Product[] }) {
  const [search, setSearch] = useState('');

  const filtered = products.filter((p) =>
    p.name.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <>
      <input onChange={(e) => setSearch(e.target.value)} />
      <ul>
        {filtered.map((p) => <ProductItem key={p.id} product={p} />)}
      </ul>
    </>
  );
}

useDeferredValue permet de différer le rendu de la liste pendant que l'input reste réactif :

function ProductList({ products }: { products: Product[] }) {
  const [search, setSearch] = useState('');
  const deferredSearch = useDeferredValue(search);

  const filtered = useMemo(
    () => products.filter((p) =>
      p.name.toLowerCase().includes(deferredSearch.toLowerCase())
    ),
    [products, deferredSearch]
  );

  return (
    <>
      <input onChange={(e) => setSearch(e.target.value)} value={search} />
      <ul>
        {filtered.map((p) => <ProductItem key={p.id} product={p} />)}
      </ul>
    </>
  );
}

L'input répond immédiatement (INP faible), le filtre se met à jour légèrement en retard (acceptable visuellement).

2. Le JavaScript tiers qui s'exécute au moment d'une interaction.

Un chat widget ou un script d'analytics qui effectue du travail lourd peut bloquer le thread au même moment qu'un clic utilisateur, même si ce script n'a rien à voir avec l'interaction. C'est ce qu'on appelle la contention de thread.

La solution qu'on a appliquée : charger tous les scripts tiers dans des Web Workers quand c'est possible, ou les différer suffisamment pour qu'ils aient terminé leur initialisation avant les premières interactions.

Yielding au thread principal

Pour des opérations nécessairement lourdes déclenchées par une interaction, on peut découper le travail et rendre le contrôle au navigateur entre les chunks :

async function processLargeDataset(items: Item[]) {
  const results: ProcessedItem[] = [];
  const CHUNK_SIZE = 50;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    results.push(...chunk.map(process));

    // Rendre le contrôle au navigateur entre chaque chunk
    await new Promise((resolve) => setTimeout(resolve, 0));
  }

  return results;
}

scheduler.yield() est une API plus propre quand elle est disponible (Chrome 115+) :

for (let i = 0; i < items.length; i += CHUNK_SIZE) {
  const chunk = items.slice(i, i + CHUNK_SIZE);
  results.push(...chunk.map(process));

  if ('scheduler' in window && 'yield' in scheduler) {
    await scheduler.yield();
  } else {
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

Ce qu'on a amélioré concrètement

Sur un tableau de bord avec des filtres et un export CSV :

  • Avant : INP mesuré à 340 ms (75e percentile CrUX), classé "à améliorer"
  • Après useDeferredValue sur les filtres + chunking sur l'export : 140 ms, classé "bon"

L'export CSV traitait 2 000 lignes de manière synchrone en réponse à un clic. Le bouton semblait gelé pendant 600 ms. Après chunking avec yielding, le bouton affiche un état de chargement et le thread reste disponible.

Ce qu'INP ne mesure pas

INP ne distingue pas une interaction volontairement lente (chargement d'un graphique complexe après un clic) d'une interaction involontairement lente (thread bloqué par un script). Du point de vue de Google, c'est la même dégradation.

C'est la limite de la métrique : on peut avoir un INP dégradé sur une fonctionnalité intentionnellement coûteuse, sans que cela reflète une mauvaise expérience utilisateur réelle. La question qu'on n'a pas encore tranchée : faut-il optimiser l'INP de features complexes pour satisfaire une métrique, même si les utilisateurs acceptent ce délai ?

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert