LCP : Largest Contentful Paint
LCP mesure le moment où l'élément de contenu visible le plus grand s'affiche. Il s'agit généralement de :
- Une image ou vidéo hero
- Un grand bloc de texte
- Une image de fond
- Un élément
<svg>
Problèmes LCP courants
1. Réponse serveur lente (TTFB > 800ms)
Fix: CDN, caching, backend optimisé, edge rendering
2. Ressources bloquantes pour le rendu
<!-- ❌ Bloque le rendu -->
<link rel="stylesheet" href="/all-styles.css">
<!-- ✅ CSS critique inséré, reste différé -->
<style>/* CSS critique above-fold */</style>
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
3. Temps de chargement de ressources lent
<!-- ❌ Pas d'indices, découvert tard -->
<img src="/hero.jpg" alt="Hero">
<!-- ✅ Préchargé avec priorité haute -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<img src="/hero.webp" alt="Hero" fetchpriority="high">
4. Délais de rendu côté client
// ❌ Contenu chargé après JavaScript
useEffect(() => {
fetch('/api/hero-text').then(r => r.json()).then(setHeroText);
}, []);
// ✅ Rendu côté serveur ou statique
// Utilisez SSR, SSG, ou streaming pour envoyer du HTML avec le contenu
export async function getServerSideProps() {
const heroText = await fetchHeroText();
return { props: { heroText } };
}
Liste de contrôle d'optimisation LCP
- [ ] TTFB < 800ms (utiliser CDN, edge caching)
- [ ] Image LCP préchargée avec fetchpriority="high"
- [ ] Image LCP optimisée (WebP/AVIF, taille correcte)
- [ ] CSS critique inséré (< 14 KB)
- [ ] Aucun JavaScript bloquant le rendu dans <head>
- [ ] Les polices n'interfèrent pas avec le rendu du texte (font-display: swap)
- [ ] Élément LCP dans le HTML initial (pas rendu par JS)
Identification de l'élément LCP
// Trouvez votre élément LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP element:', lastEntry.element);
console.log('LCP time:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
INP : Interaction to Next Paint
INP mesure la réactivité sur TOUTES les interactions (clics, appuis, pressions clavier) lors d'une visite de page. Il rapporte la pire interaction (au 98e percentile pour les pages à fort trafic).
Décomposition INP
INP total = Input Delay + Processing Time + Presentation Delay
| Phase | Cible | Optimisation |
|---|---|---|
| Input Delay | < 50ms | Réduire le blocage du thread principal |
| Processing | < 100ms | Optimiser les gestionnaires d'événements |
| Presentation | < 50ms | Minimiser le travail de rendu |
Problèmes INP courants
1. Tâches longues bloquant le thread principal
// ❌ Tâche synchrone longue
function processLargeArray(items) {
items.forEach(item => expensiveOperation(item));
}
// ✅ Diviser en chunks avec cession du contrôle
async function processLargeArray(items) {
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => expensiveOperation(item));
// Cédez le thread principal
await new Promise(r => setTimeout(r, 0));
// Ou utilisez scheduler.yield() si disponible
}
}
2. Gestionnaires d'événements lourds
// ❌ Tout le travail dans le gestionnaire
button.addEventListener('click', () => {
// Calcul lourd
const result = calculateComplexThing();
// Mises à jour DOM
updateUI(result);
// Analytics
trackEvent('click');
});
// ✅ Prioriser le retour visuel
button.addEventListener('click', () => {
// Retour visuel immédiat
button.classList.add('loading');
// Différer le travail non critique
requestAnimationFrame(() => {
const result = calculateComplexThing();
updateUI(result);
});
// Utiliser requestIdleCallback pour analytics
requestIdleCallback(() => trackEvent('click'));
});
3. Scripts tiers
// ❌ Chargé activement, bloque les interactions
<script src="https://heavy-widget.com/widget.js"></script>
// ✅ Chargé paresseusement à l'interaction ou visibilité
const loadWidget = () => {
import('https://heavy-widget.com/widget.js')
.then(widget => widget.init());
};
button.addEventListener('click', loadWidget, { once: true });
4. Re-rendus excessifs (React/Vue)
// ❌ Re-rend toute l'arborescence
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} />
<ExpensiveComponent /> {/* Re-rend à chaque changement de count */}
</div>
);
}
// ✅ Composants coûteux mémorisés
const MemoizedExpensive = React.memo(ExpensiveComponent);
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} />
<MemoizedExpensive />
</div>
);
}
Liste de contrôle d'optimisation INP
- [ ] Aucune tâche > 50ms sur le thread principal
- [ ] Les gestionnaires d'événements se complètent rapidement (< 100ms)
- [ ] Retour visuel fourni immédiatement
- [ ] Travail lourd différé avec requestIdleCallback
- [ ] Scripts tiers ne bloquent pas les interactions
- [ ] Gestionnaires d'entrée debounced le cas échéant
- [ ] Web Workers pour les opérations gourmandes en CPU
Débogage INP
// Identifier les interactions lentes
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn('Slow interaction:', {
type: entry.name,
duration: entry.duration,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
target: entry.target
});
}
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
CLS : Cumulative Layout Shift
CLS mesure les changements de mise en page inattendus. Un décalage se produit quand un élément visible change de position entre les images sans interaction utilisateur.
Formule CLS : impact fraction × distance fraction
Causes CLS courantes
1. Images sans dimensions
<!-- ❌ Provoque un changement de mise en page au chargement -->
<img src="photo.jpg" alt="Photo">
<!-- ✅ Espace réservé -->
<img src="photo.jpg" alt="Photo" width="800" height="600">
<!-- ✅ Ou utiliser aspect-ratio -->
<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;">
2. Annonces, intégrations et iframes
<!-- ❌ Taille inconnue jusqu'au chargement -->
<iframe src="https://ad-network.com/ad"></iframe>
<!-- ✅ Réserver l'espace avec min-height -->
<div style="min-height: 250px;">
<iframe src="https://ad-network.com/ad" height="250"></iframe>
</div>
<!-- ✅ Ou utiliser un conteneur aspect-ratio -->
<div style="aspect-ratio: 16/9;">
<iframe src="https://youtube.com/embed/..."
style="width: 100%; height: 100%;"></iframe>
</div>
3. Contenu injecté dynamiquement
// ❌ Insère du contenu au-dessus de la fenêtre
notifications.prepend(newNotification);
// ✅ Insérer au-dessous ou utiliser transform
const insertBelow = viewport.bottom < newNotification.top;
if (insertBelow) {
notifications.prepend(newNotification);
} else {
// Animer sans décaler
newNotification.style.transform = 'translateY(-100%)';
notifications.prepend(newNotification);
requestAnimationFrame(() => {
newNotification.style.transform = '';
});
}
4. Polices web causant FOUT
/* ❌ Échange de police décale le texte */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
}
/* ✅ Police optionnelle (pas de décalage si lente) */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: optional;
}
/* ✅ Ou correspondre aux métriques de secours */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: swap;
size-adjust: 105%; /* Correspondre à la taille de secours */
ascent-override: 95%;
descent-override: 20%;
}
5. Animations déclenchant la mise en page
/* ❌ Anime les propriétés de mise en page */
.animate {
transition: height 0.3s, width 0.3s;
}
/* ✅ Utiliser transform à la place */
.animate {
transition: transform 0.3s;
}
.animate.expanded {
transform: scale(1.2);
}
Liste de contrôle d'optimisation CLS
- [ ] Toutes les images ont width/height ou aspect-ratio
- [ ] Toutes les vidéos/intégrations ont de l'espace réservé
- [ ] Les annonces ont des conteneurs min-height
- [ ] Les polices utilisent font-display: optional ou métriques correspondantes
- [ ] Contenu dynamique inséré au-dessous de la fenêtre
- [ ] Les animations utilisent uniquement transform/opacity
- [ ] Aucun contenu injecté au-dessus du contenu existant
Débogage CLS
// Suivre les changements de mise en page
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry.value);
entry.sources?.forEach(source => {
console.log(' Shifted element:', source.node);
console.log(' Previous rect:', source.previousRect);
console.log(' Current rect:', source.currentRect);
});
}
}
}).observe({ type: 'layout-shift', buffered: true });
Outils de mesure
Tests en laboratoire
- Chrome DevTools → Panneau Performance, Lighthouse
- WebPageTest → Waterfall détaillé, filmstrip
- Lighthouse CLI →
npx lighthouse <url>
Données de terrain (utilisateurs réels)
- Chrome User Experience Report (CrUX) → BigQuery ou API
- Search Console → Rapport Core Web Vitals
- web-vitals library → Envoyer à votre analytics
import {onLCP, onINP, onCLS} from 'web-vitals';
function sendToAnalytics({name, value, rating}) {
gtag('event', name, {
event_category: 'Web Vitals',
value: Math.round(name === 'CLS' ? value * 1000 : value),
event_label: rating
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Corrections rapides par framework
Next.js
// LCP: Utiliser next/image avec priority
import Image from 'next/image';
<Image src="/hero.jpg" priority fill alt="Hero" />
// INP: Utiliser les imports dynamiques
const HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false });
// CLS: Le composant Image gère les dimensions automatiquement
React
// LCP: Précharger dans le head
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />
// INP: Mémoriser et useTransition
const [isPending, startTransition] = useTransition();
startTransition(() => setExpensiveState(newValue));
// CLS: Toujours spécifier les dimensions dans les tags img
Vue/Nuxt
<!-- LCP: Utiliser nuxt/image avec preload -->
<NuxtImg src="/hero.jpg" preload loading="eager" />
<!-- INP: Utiliser les composants async -->
<component :is="() => import('./Heavy.vue')" />
<!-- CLS: Utiliser aspect-ratio CSS -->
<img :style="{ aspectRatio: '16/9' }" />