Un e-commerce de 12 000 fiches produit, framework React, INP moyen à 487 ms sur mobile. Après le remplacement de FID par INP dans les Core Web Vitals en mars 2024, 63 % de leurs pages sont passées en "Poor" dans la Search Console. Le trafic organique a stagné pendant que les concurrents sous la barre des 200 ms captaient les positions perdues. Ce n'est pas un cas hypothétique — c'est le pattern le plus fréquent sur les SPA lourdes en JavaScript.
INP ne mesure pas la même chose que FID. FID mesurait le délai avant que le navigateur commence à traiter le premier événement. INP mesure la latence complète — du clic (ou tap, ou keypress) jusqu'au prochain frame peint à l'écran. C'est la différence entre "le serveur a reçu votre commande" et "le plat est sur la table".
Ce que mesure réellement INP (et pourquoi FID était insuffisant)
FID capturait un seul instant : le input delay de la première interaction. Si votre page chargeait un bundle de 2 Mo et que l'utilisateur cliquait pendant l'exécution, FID enregistrait ce délai. Mais il ignorait complètement :
- Le processing time (le temps d'exécution de vos event handlers)
- Le presentation delay (le temps entre la fin du traitement et le rendu du frame suivant)
- Toutes les interactions après la première
INP agrège toutes les interactions de la session utilisateur et retient la pire (avec un mécanisme de lissage : sur les pages avec beaucoup d'interactions, le pire percentile est écarté). Le seuil Google est clair : en dessous de 200 ms c'est "Good", entre 200 et 500 ms c'est "Needs Improvement", au-delà c'est "Poor".
Concrètement, l'INP se décompose en trois phases :
┌─────────────────────────────────────────────────────┐
│ INP total │
│ │
│ ┌──────────┐ ┌────────────────┐ ┌─────────────┐ │
│ │ Input │ │ Processing │ │ Presentation│ │
│ │ Delay │ │ Time │ │ Delay │ │
│ │ │ │ (handlers) │ │ (rendering) │ │
│ └──────────┘ └────────────────┘ └─────────────┘ │
│ │
│ Le main thread Vos event Layout, Paint, │
│ est occupé handlers Composite │
│ ailleurs s'exécutent │
└─────────────────────────────────────────────────────┘
La nuance importante : une page peut avoir un excellent FID (le main thread est libre au premier clic) mais un INP catastrophique (chaque interaction déclenche un recalcul de layout de 300 ms). C'est exactement ce qui se passe sur les SPA avec state management lourd — Redux, Zustand ou MobX qui déclenchent des re-renders en cascade.
Pour approfondir l'impact réel des Core Web Vitals sur le ranking, consultez cet article dédié.
Mesurer l'INP : données terrain vs données lab
Données terrain (RUM)
L'INP est une métrique field-only dans sa version officielle. Le Chrome User Experience Report (CrUX) agrège les données des utilisateurs réels de Chrome. Vous les retrouvez dans :
- PageSpeed Insights : le rapport le plus accessible, avec la distinction Origin Summary vs URL spécifique
- Search Console > Core Web Vitals : vue groupée par type de pages, avec l'évolution temporelle
- CrUX API : pour intégrer les données dans vos dashboards
Le piège classique : CrUX agrège les données sur 28 jours glissants. Si vous déployez un fix INP un lundi, vous ne verrez l'amélioration dans CrUX que 4 semaines plus tard. Pour un suivi en temps réel, vous devez instrumenter votre propre collecte RUM.
Voici un snippet de collecte INP avec la bibliothèque web-vitals de Google :
import { onINP } from 'web-vitals';
onINP((metric) => {
const entry = metric.entries[metric.entries.length - 1];
const eventTarget = entry.target?.localName || 'unknown';
const eventType = entry.name; // 'click', 'keydown', 'pointerup'
// Décomposition des trois phases
const inputDelay = entry.processingStart - entry.startTime;
const processingTime = entry.processingEnd - entry.processingStart;
const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;
// Envoi vers votre endpoint analytics
navigator.sendBeacon('/api/rum', JSON.stringify({
page: window.location.pathname,
inp: metric.value,
inputDelay: Math.round(inputDelay),
processingTime: Math.round(processingTime),
presentationDelay: Math.round(presentationDelay),
target: eventTarget,
eventType,
// Identifiant de la page template pour grouper
pageTemplate: document.querySelector('meta[name="page-template"]')?.content
}));
}, { reportAllChanges: true });
L'option reportAllChanges: true est essentielle en phase de diagnostic. Sans elle, vous n'obtenez que la valeur finale — vous perdez la granularité nécessaire pour identifier quelle interaction est responsable.
Données lab
Chrome DevTools et Lighthouse ne mesurent pas directement l'INP (c'est une métrique de session complète). Mais le Performance panel de DevTools est votre meilleur allié pour diagnostiquer les interactions lentes.
La procédure concrète :
- Ouvrez DevTools > Performance
- Activez "Interactions" dans la timeline
- Lancez l'enregistrement, effectuez l'interaction problématique
- Arrêtez l'enregistrement
- Cherchez la piste "Interactions" — les barres rouges signalent les interactions > 200 ms
- Cliquez sur l'interaction pour voir exactement quels Long Tasks bloquent le main thread
Depuis Chrome 122, la piste "Interactions" affiche directement la décomposition input delay / processing time / presentation delay. C'est le diagnostic le plus fiable que vous puissiez obtenir en lab.
Les cinq causes racines d'un INP lent (et comment les corriger)
1. Event handlers synchrones trop lourds
Le cas le plus fréquent. Un clic sur "Ajouter au panier" déclenche une chaîne : mise à jour du state, recalcul du prix, re-render du header (compteur panier), re-render du drawer panier, éventuellement un appel tracking synchrone.
Sur un site e-commerce Shopify headless en React, un audit a révélé que le handler addToCart déclenchait 14 re-renders synchrones en cascade via un context React global. Chaque re-render touchait 200+ composants. Processing time : 340 ms.
La solution : yield to the main thread entre les étapes critiques.
// Avant : tout synchrone, 340 ms de processing
function handleAddToCart(productId, variant) {
cartStore.addItem(productId, variant); // déclenche re-render global
trackingService.trackAddToCart(productId); // synchrone, 80 ms
toastNotification.show('Ajouté'); // layout shift + paint
miniCart.open(); // animation + re-render
}
// Après : yield entre chaque étape
async function handleAddToCart(productId, variant) {
// Étape 1 : mise à jour critique (ce que l'utilisateur attend)
cartStore.addItem(productId, variant);
// Yield : laisser le navigateur peindre le frame
await scheduler.yield();
// Étape 2 : tracking (non visible pour l'utilisateur)
trackingService.trackAddToCart(productId);
await scheduler.yield();
// Étape 3 : UI secondaire
toastNotification.show('Ajouté');
miniCart.open();
}
scheduler.yield() est disponible depuis Chrome 129 (cf. documentation web.dev). Pour les navigateurs plus anciens, le fallback classique :
function yieldToMain() {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Le setTimeout(0) n'est pas parfait — il a un délai minimum de ~4 ms et passe derrière les autres tasks en queue — mais il reste efficace pour découper un processing de 300 ms en morceaux de 50-80 ms.
2. Input delay causé par des scripts tiers
Le main thread est occupé par un script tiers au moment de l'interaction. Le navigateur ne peut pas commencer à exécuter votre handler tant que le Long Task en cours ne se termine pas.
Les suspects habituels : Google Tag Manager avec 15+ tags, scripts A/B testing (Optimizely, VWO), widgets de chat (Intercom, Drift), consent management platforms.
Diagnostic avec Chrome DevTools : dans le Performance panel, cherchez les Long Tasks (barres rouges dans la piste Main) qui précèdent immédiatement votre interaction. L'appel Evaluate Script vous indiquera quel fichier est en cause.
Solutions par ordre d'impact :
Différer les scripts non critiques avec requestIdleCallback :
// Au lieu de charger GTM au DOMContentLoaded
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
loadGTM('GTM-XXXXXX');
}, { timeout: 5000 }); // fallback après 5s
} else {
setTimeout(() => loadGTM('GTM-XXXXXX'), 3000);
}
Isoler les scripts tiers dans des Web Workers quand c'est possible (Partytown est une option, mais attention — Partytown a des limitations réelles avec les scripts qui accèdent au DOM, notamment les outils de session replay).
Auditer les tags GTM un par un. Chaque tag a un coût. Sur un projet média (8 000 pages), la suppression de 6 tags obsolètes dans GTM a réduit l'input delay moyen de 120 ms à 35 ms.
3. Re-renders excessifs sur les frameworks réactifs
React, Vue, Svelte — tous les frameworks à virtual DOM peuvent générer des re-renders coûteux quand le state management est mal architecturé.
Le pattern toxique classique en React :
// ❌ Un context global qui change à chaque interaction
const AppContext = createContext();
function AppProvider({ children }) {
const [cart, setCart] = useState([]);
const [user, setUser] = useState(null);
const [ui, setUi] = useState({ menuOpen: false, modalOpen: false });
// Chaque setUi re-render TOUT l'arbre
return (
<AppContext.Provider value={{ cart, setCart, user, setUser, ui, setUi }}>
{children}
</AppContext.Provider>
);
}
La correction : splitez vos contexts par domaine et utilisez useMemo / useCallback de manière ciblée. Ou mieux, passez à un state manager avec subscription sélective (Zustand, Jotai) qui ne re-render que les composants abonnés au slice modifié.
// ✅ Stores isolés avec Zustand — seuls les consommateurs du slice re-render
import { create } from 'zustand';
const useCartStore = create((set) => ({
items: [],
addItem: (product) => set((state) => ({
items: [...state.items, product]
})),
}));
const useUIStore = create((set) => ({
menuOpen: false,
toggleMenu: () => set((state) => ({ menuOpen: !state.menuOpen })),
}));
// Le header ne re-render que quand items.length change
function CartBadge() {
const count = useCartStore((state) => state.items.length);
return <span className="badge">{count}</span>;
}
Pour les sites rendus côté serveur avec des problèmes de réactivité post-hydration, le guide sur les hydration mismatches couvre les pièges spécifiques à l'hydration.
4. Layout thrashing dans les handlers
Le layout thrashing survient quand votre code lit puis écrit des propriétés de layout de manière alternée dans la même frame. Le navigateur doit recalculer le layout à chaque lecture.
// ❌ Layout thrashing : chaque itération force un reflow
function resizeCards(cards) {
cards.forEach(card => {
const width = card.offsetWidth; // lecture → force layout
card.style.height = `${width * 1.5}px`; // écriture → invalide layout
});
}
// ✅ Batch reads, then batch writes
function resizeCards(cards) {
// Phase lecture
const widths = cards.map(card => card.offsetWidth);
// Phase écriture
cards.forEach((card, i) => {
card.style.height = `${widths[i] * 1.5}px`;
});
}
Sur une page catalogue avec 48 produits par page, le pattern thrashing générait un processing time de 180 ms juste pour le recalcul des cartes au clic sur un filtre. La version batchée : 12 ms.
Pour détecter le layout thrashing : dans DevTools > Performance, cherchez les blocs violets "Layout" répétés dans un même handler. Chaque bloc violet = un forced reflow.
5. Presentation delay : animations et paint coûteux
Le troisième segment de l'INP est souvent négligé. Après l'exécution de vos handlers, le navigateur doit calculer les styles, le layout, peindre les pixels et les compositor. Si votre interaction déclenche un changement de layout sur un grand arbre DOM, le presentation delay explose.
Règles concrètes :
- Animez uniquement
transformetopacity— ces propriétés sont composited, elles ne déclenchent ni layout ni paint. Untransition: height 300mssur un accordion avec 50 items enfants est un tueur d'INP. - Utilisez
content-visibility: autosur les sections hors viewport. Le navigateur skip le rendering des éléments non visibles. - Évitez les sélecteurs CSS complexes dans les parties dynamiques. Un sélecteur
.product-list > .card:nth-child(odd) .price::afterforce le navigateur à évaluer beaucoup de nœuds à chaque recalcul de style.
/* ❌ L'accordion anime height — déclenche layout sur tout le flux */
.accordion-panel {
transition: height 300ms ease;
overflow: hidden;
}
/* ✅ L'accordion utilise grid + transform — composited only */
.accordion-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease;
}
.accordion-panel[open] {
grid-template-rows: 1fr;
}
.accordion-panel > .inner {
overflow: hidden;
}
La technique grid-template-rows: 0fr → 1fr est supportée depuis Chrome 111, Firefox 118 et Safari 17. Elle permet une animation de hauteur fluide sans déclencher de layout sur les éléments siblings.
Scénario concret : migration d'un catalogue e-commerce de 15 000 pages
Contexte : un retailer mode avec 15 000 fiches produit, construit en React SPA avec client-side rendering. Après migration vers Next.js 14 avec App Router (SSR), le LCP s'améliore significativement, mais l'INP reste à 420 ms au P75 sur mobile. Le trafic organique stagne malgré le gain LCP.
Diagnostic avec les données RUM collectées via web-vitals :
| Interaction | % des occurrences | INP moyen | Phase dominante |
|---|---|---|---|
| Clic filtre taille | 31% | 520 ms | Processing (380 ms) |
| Ajout panier | 24% | 410 ms | Processing (290 ms) |
| Clic swatch couleur | 18% | 380 ms | Presentation (210 ms) |
| Navigation menu | 15% | 280 ms | Input delay (180 ms) |
| Recherche autocomplete | 12% | 190 ms | OK |
Fix 1 — Filtres produit (priorité maximale) : le clic sur un filtre taille déclenchait un setState sur le tableau complet de 48 produits, chaque ProductCard re-rendait car la référence de products changeait. Solution : pagination virtuelle avec react-window + selector Zustand granulaire. Processing réduit de 380 ms à 65 ms.
Fix 2 — Ajout panier : le handler était synchrone et incluait un appel dataLayer.push() bloquant de GTM. Déplacement du tracking après un scheduler.yield(). Processing réduit de 290 ms à 80 ms.
Fix 3 — Swatch couleur : le changement de couleur déclenchait un swap d'image haute résolution + un changement de background-color animé en CSS sur le container parent (1200×800 px). L'image haute résolution était chargée synchronement dans le handler. Solution : content-visibility: auto sur les images hors viewport + préchargement des variantes au hover + animation limitée à opacity. Presentation delay réduit de 210 ms à 40 ms.
Fix 4 — Navigation menu : input delay causé par un script Hotjar qui exécutait un Long Task de 200 ms toutes les 5 secondes. Déplacement de Hotjar dans un requestIdleCallback avec timeout de 8 secondes.
Résultat après 28 jours (temps de propagation CrUX) : INP P75 passé de 420 ms à 145 ms. 94 % des pages en "Good". Dans les 6 semaines suivantes, les pages produit ont gagné en moyenne 2,3 positions sur les requêtes transactionnelles — corrélation, pas nécessairement causalité directe, mais cohérent avec l'intégration d'INP dans le signal Page Experience documentée par Google.
Pour les sites qui souffrent à la fois de problèmes de rendering et de réactivité, le choix du mode de rendering (ISR, SSR, SSG) impacte directement la quantité de JavaScript client — et donc l'INP.
Automatiser la détection des régressions INP
L'INP est une métrique fragile. Un développeur ajoute un event listener mal optimisé sur un composant partagé (header, footer), et l'INP de 100 % des pages régresse en une release. Vous ne pouvez pas surveiller ça manuellement sur 15 000 pages.
Intégration CI/CD avec Lighthouse User Flows
Lighthouse supporte depuis la v10 les "user flows" qui simulent des interactions et mesurent des métriques proches de l'INP (bien que la métrique lab exacte soit TBT, qui corrèle fortement avec INP).
// lighthouse-user-flow.mjs — à intégrer dans votre pipeline CI
import puppeteer from 'puppeteer';
import { startFlow } from 'lighthouse';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
// Émulation mobile
await page.emulateMediaFeatures([
{ name: 'prefers-reduced-motion', value: 'no-preference' },
]);
const flow = await startFlow(page, { name: 'Product page interactions' });
// Step 1 : Navigation
await flow.navigate('https://www.votre-ecommerce.fr/produit/veste-en-lin-xyz');
// Step 2 : Interaction — clic sur filtre taille
await flow.startTimespan({ stepName: 'Click size filter' });
await page.click('[data-testid="size-filter-M"]');
await page.waitForSelector('[data-testid="product-grid"][data-filtered="true"]');
await flow.endTimespan();
// Step 3 : Interaction — ajout panier
await flow.startTimespan({ stepName: 'Add to cart' });
await page.click('[data-testid="add-to-cart"]');
await page.waitForSelector('[data-testid="cart-badge"]');
await flow.endTimespan();
const report = await flow.generateReport();
// Parse le rapport, fail la CI si TBT > seuil
const timespans = JSON.parse(report).steps.filter(s => s.lhr.gatherMode === 'timespan');
for (const step of timespans) {
const tbt = step.lhr.audits['total-blocking-time'].numericValue;
if (tbt > 150) {
console.error(`❌ ${step.name}: TBT ${tbt}ms exceeds 150ms threshold`);
process.exit(1);
}
}
await browser.close();
Ce script dans votre pipeline GitHub Actions ou GitLab CI bloque tout merge qui dégrade la réactivité mesurable. Adaptez les sélecteurs data-testid à votre markup.
Monitoring continu en production
La CI attrape les régressions avant le déploiement. Mais certains problèmes n'apparaissent qu'en production — scripts tiers mis à jour, CDN qui ralentit, A/B test qui injecte du JavaScript supplémentaire.
Pour le monitoring production, la collecte RUM (le snippet web-vitals montré plus haut) doit alimenter un système d'alerting. Un outil de monitoring SEO technique comme SEOGard permet de détecter automatiquement les régressions de performance sur l'ensemble de vos pages, sans attendre les 28 jours de CrUX pour réagir.
L'alerte doit être granulaire : pas juste "l'INP du site a augmenté", mais "l'INP des pages /produit/* a augmenté de 60 % depuis le déploiement de 14h32". C'est la différence entre un signal actionable et du bruit.
INP et SEO : au-delà du ranking direct
L'impact d'INP sur le SEO ne se limite pas au signal Page Experience. Une interaction lente a des effets en cascade :
Taux de rebond et signaux d'engagement. Un utilisateur qui clique sur un filtre et attend 500 ms sans feedback visuel a une probabilité significativement plus élevée de revenir aux résultats de recherche. Ce comportement de pogo-sticking est un signal négatif, indépendamment du score Core Web Vitals.
Crawl budget indirect. Un site lent côté client est souvent lent côté serveur aussi. Si votre JavaScript client est lourd, il y a de fortes chances que votre SSR soit lourd également — ce qui impacte le temps de réponse serveur et la capacité de Googlebot à crawler efficacement votre inventaire. Le détail de ce que Google voit réellement dépend aussi de la performance de votre rendering.
Conversion. Un INP sous les 200 ms n'est pas qu'un objectif SEO — c'est un objectif business. Chaque milliseconde de latence perçue entre un clic et sa réponse visuelle érode la confiance. Sur un tunnel d'achat, c'est mesurable en taux de conversion.
Le diagnostic LCP est souvent fait en premier car plus visible, mais l'INP a un impact utilisateur potentiellement plus fort — un LCP lent retarde le premier affichage, un INP lent dégrade chaque interaction. Pour un diagnostic LCP complémentaire, le guide LCP couvre les stratégies d'optimisation côté serveur et réseau.
Checklist INP pour un audit rapide
Avant de plonger dans les DevTools, vérifiez ces points dans l'ordre :
1. Identifiez l'interaction la plus lente. Search Console > Core Web Vitals > cliquez sur un groupe d'URLs "Poor" > notez le type de page. Puis injectez le snippet web-vitals avec reportAllChanges sur ce template pour identifier l'interaction spécifique.
2. Décomposez les trois phases. Le fix est radicalement différent selon que le problème est dans l'input delay (scripts tiers), le processing (vos handlers) ou le presentation delay (CSS/layout).
3. Profitez des Long Animation Frames (LoAF). L'API Long Animation Frames (Chrome 123+) donne un détail que Performance Observer seul ne fournit pas — elle identifie les scripts spécifiques responsables de chaque frame lente.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 150) {
console.log('Long Animation Frame:', entry.duration, 'ms');
entry.scripts.forEach(script => {
console.log(` Script: ${script.sourceURL}`);
console.log(` Function: ${script.sourceFunctionName}`);
console.log(` Char position: ${script.sourceCharPosition}`);
});
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Ce code vous donne le nom de la fonction et la position dans le fichier source qui bloque le main thread. C'est le diagnostic le plus précis disponible à ce jour pour les problèmes d'INP en production.
4. Priorisez par volume d'interactions. Un INP de 600 ms sur une modale rarement ouverte a moins d'impact qu'un INP de 250 ms sur le clic "Ajouter au panier" utilisé par 40 % des visiteurs. Pondérez votre effort par la fréquence d'interaction × la sévérité.
L'INP est la métrique Core Web Vitals la plus difficile à optimiser car elle touche à l'architecture JavaScript de votre application, pas juste à la configuration serveur ou au chargement des assets. Chaque interaction est un point de mesure potentiel, et une seule régression dans un composant partagé peut contaminer l'ensemble du site. La clé : une collecte RUM granulaire, un pipeline CI qui bloque les régressions, et un monitoring continu — exactement le type de surveillance automatisée que SEOGard applique aux signaux SEO techniques critiques.