Introduction
Les Core Web Vitals sont des métriques essentielles définies par Google pour mesurer l’expérience utilisateur réelle sur le web. Depuis 2021, ces métriques influencent directement le référencement SEO.
Pourquoi c’est critique
Impact mesurable des Core Web Vitals:
├── Conversion rate: +24% avec amélioration LCP
├── Bounce rate: -32% avec bon CLS
├── Session duration: +43% avec FID optimal
└── SEO ranking: Facteur de classement Google
ROI mesuré: Amazon a constaté qu’une latence de 100ms coûte 1% de ventes.
1. Les trois métriques Core Web Vitals
1.1 LCP (Largest Contentful Paint)
Définition: Temps nécessaire pour afficher le plus grand élément de contenu visible.
Seuils de performance
Excellent : LCP ≤ 2.5s
À améliorer : 2.5s < LCP ≤ 4.0s
Mauvais : LCP > 4.0s
Exemple: Optimisation LCP réelle
Avant optimisation (LCP: 4.2s):
Après optimisation (LCP: 1.8s):
Gains mesurés:
- LCP: 4.2s → 1.8s (-57%)
- Poids image: 450KB → 85KB (-81%)
- Conversion: +18%
1.2 FID (First Input Delay) → INP (2024)
Définition: Temps entre l’interaction utilisateur et la réponse du navigateur.
Note: Google remplace FID par INP (Interaction to Next Paint) en 2024.
Seuils de performance
FID:
├── Excellent : ≤ 100ms
├── À améliorer : 100-300ms
└── Mauvais : > 300ms
INP (nouveau):
├── Excellent : ≤ 200ms
├── À améliorer : 200-500ms
└── Mauvais : > 500ms
Exemple: Réduction du FID/INP
Avant (FID: 380ms):
// Bloque le thread principal
document.addEventListener('DOMContentLoaded', () => {
// Traitement synchrone lourd
const data = processHugeDataset(dataset);
updateUI(data);
initializeCharts(data);
setupEventListeners();
});
function processHugeDataset(dataset) {
// Boucle bloquante de 400ms
return dataset.map(item => {
return heavyCalculation(item);
});
}
Après (FID: 45ms):
// Utilisation de Web Workers + code splitting
document.addEventListener('DOMContentLoaded', async () => {
// Event listeners critiques d'abord
setupCriticalEventListeners();
// Defer non-critical
requestIdleCallback(() => {
initializeNonCriticalFeatures();
});
// Web Worker pour traitement lourd
const worker = new Worker('/js/data-processor.worker.js');
worker.postMessage({ dataset });
worker.onmessage = (e) => {
updateUI(e.data);
};
});
// data-processor.worker.js
self.onmessage = function(e) {
const result = processHugeDataset(e.data.dataset);
self.postMessage(result);
};
Stratégie de code splitting:
// Lazy load des fonctionnalités lourdes
const setupCriticalEventListeners = () => {
document.querySelector('.menu-toggle').addEventListener('click',
async (e) => {
const { initMenu } = await import('./menu.js');
initMenu();
},
{ once: true }
);
};
// requestIdleCallback avec fallback
const requestIdleCallback = window.requestIdleCallback ||
function(cb) { setTimeout(cb, 1); };
Gains mesurés:
1.3 CLS (Cumulative Layout Shift)
Définition: Mesure l’instabilité visuelle du contenu pendant le chargement.
Seuils de performance
Excellent : CLS ≤ 0.1
À améliorer : 0.1 < CLS ≤ 0.25
Mauvais : CLS > 0.25
Exemple: Élimination du CLS
Avant (CLS: 0.42):
Titre
Contenu de l'article...
Après (CLS: 0.02):
# Titre
Contenu de l'article...
Skeleton screen pour async content:
/ Placeholder pendant le chargement /
.content-loading {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// Gestion du chargement asynchrone
class AdManager {
loadAd(slot) {
// Mesure l'espace avant
const beforeHeight = slot.offsetHeight;
// Charge l'annonce
this.fetchAd(slot).then(ad => {
slot.innerHTML = ad;
// Vérifie si la hauteur a changé
const afterHeight = slot.offsetHeight;
if (afterHeight !== beforeHeight) {
console.warn('CLS detecté:', afterHeight - beforeHeight);
}
});
}
}
Gains mesurés:
2. Outils de mesure et profiling
2.1 Lighthouse CI (Automatisation)
Installation et configuration:
# Installation
npm install -g @lhci/cli
# Configuration
cat > lighthouserc.json << 'EOF'
{
"ci": {
"collect": {
"numberOfRuns": 5,
"url": [
"http://localhost:3000/",
"http://localhost:3000/blog",
"http://localhost:3000/products"
],
"settings": {
"preset": "desktop",
"throttling": {
"cpuSlowdownMultiplier": 1
}
}
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["error", {"minScore": 0.9}],
"largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
"total-blocking-time": ["error", {"maxNumericValue": 200}]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
EOF
# Exécution
lhci autorun
Intégration GitHub Actions:
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push, pullrequest]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun --upload.target=temporary-public-storage
env:
LHCIGITHUBAPPTOKEN: ${{ secrets.LHCIGITHUBAPPTOKEN }}
- name: Upload results
uses: actions/upload-artifact@v3
with:
name: lighthouse-results
path: .lighthouseci
Résultats exemple:
Lighthouse CI Results:
┌────────────────────┬──────────┬──────────┬──────────┐
│ Category │ Before │ After │ Change │
├────────────────────┼──────────┼──────────┼──────────┤
│ Performance │ 62 │ 94 │ +52% │
│ LCP │ 4.2s │ 1.8s │ -57% │
│ TBT │ 520ms │ 80ms │ -85% │
│ CLS │ 0.42 │ 0.02 │ -95% │
└────────────────────┴──────────┴──────────┴──────────┘
2.2 Chrome DevTools Performance
Profiling détaillé:
// API Performance pour mesures custom
class PerformanceMonitor {
static mark(name) {
performance.mark(name);
}
static measure(name, startMark, endMark) {
performance.measure(name, startMark, endMark);
const measure = performance.getEntriesByName(name)[0];
// Envoi vers analytics
this.reportMetric(name, measure.duration);
return measure;
}
static reportMetric(name, value) {
// Log local
console.log([Perf] ${name}: ${value.toFixed(2)}ms);
// Envoi vers backend analytics
if (navigator.sendBeacon) {
const data = JSON.stringify({
metric: name,
value: value,
timestamp: Date.now(),
url: location.href
});
navigator.sendBeacon('/api/metrics', data);
}
}
// Mesure des Core Web Vitals
static observeWebVitals() {
// LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.reportMetric('LCP', lastEntry.renderTime || lastEntry.loadTime);
}).observe({ entryTypes: ['largest-contentful-paint'] });
// FID
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
this.reportMetric('FID', entry.processingStart - entry.startTime);
});
}).observe({ entryTypes: ['first-input'] });
// CLS
let clsScore = 0;
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) {
clsScore += entry.value;
this.reportMetric('CLS', clsScore);
}
});
}).observe({ entryTypes: ['layout-shift'] });
}
}
// Utilisation
PerformanceMonitor.mark('app-start');
// ... code de l'application ...
PerformanceMonitor.mark('app-ready');
PerformanceMonitor.measure('app-init', 'app-start', 'app-ready');
// Observation automatique
PerformanceMonitor.observeWebVitals();
Workflow de profiling:
1. Ouvrir DevTools (F12) → Performance tab
Activer options:
├── Screenshots (voir évolution visuelle)
├── Memory (détecter leaks)
└── Web Vitals (annotations automatiques)
Enregistrer (Ctrl+E) pendant 10-15s
Analyser:
├── Main thread (chercher long tasks > 50ms)
├── Network waterfall (requêtes bloquantes)
├── Frames (maintenir 60 FPS = 16.67ms/frame)
└── Layout shifts (CLS events)
2.3 WebPageTest (Tests distribués)
Configuration avancée:
// Script WebPageTest pour scénarios complexes
// webpagetest-script.txt
// Se connecter avant de mesurer
logData 0
navigate https://example.com/login
setValue id=username testuser
setValue id=password testpass
submitForm id=login-form
logData 1
// Mesurer la page dashboard
navigate https://example.com/dashboard
// Interagir et mesurer
click id=load-more-button
sleep 2000
// Custom metrics
exec const lcp = document.querySelector('[elementtiming="hero-image"]')
execAndWait const timing = performance.getEntriesByType('largest-contentful-paint')[0].renderTime
API WebPageTest (automatisation):
// Node.js script pour tests réguliers
const WebPageTest = require('webpagetest');
const wpt = new WebPageTest('www.webpagetest.org', 'YOURAPIKEY');
async function runTest() {
const options = {
location: 'ec2-eu-west-1:Chrome',
connectivity: '4G',
runs: 3,
firstViewOnly: false,
video: true,
lighthouse: true,
pollResults: 5
};
wpt.runTest('https://example.com', options, (err, result) => {
if (err) throw err;
console.log('Test ID:', result.data.testId);
console.log('Results URL:', result.data.summaryCSV);
// Récupérer les métriques
wpt.getTestResults(result.data.testId, (err, data) => {
const metrics = data.data.median.firstView;
console.log({
LCP: metrics.chromeUserTiming.LargestContentfulPaint,
FID: metrics.chromeUserTiming.FirstInputDelay,
CLS: metrics.chromeUserTiming.CumulativeLayoutShift,
SpeedIndex: metrics.SpeedIndex,
TTFB: metrics.TTFB
});
// Alertes si dégradation
if (metrics.chromeUserTiming.LargestContentfulPaint > 2500) {
sendAlert('LCP dégradé', metrics);
}
});
});
}
runTest();
3. Stratégies d'optimisation globales
3.1 Resource Hints
Types et cas d'usage:
Impact mesuré:
Preconnect vers CDN:
├── TTFB: -120ms (-35%)
└── Font load: -200ms (-40%)
Preload critical CSS:
├── FCP: -400ms (-30%)
└── LCP: -350ms (-20%)
3.2 Critical CSS Inline
Extraction automatique:
// critical-css.js
const critical = require('critical');
critical.generate({
inline: true,
base: 'dist/',
src: 'index.html',
target: {
html: 'index-critical.html',
css: 'critical.css'
},
dimensions: [
{ width: 375, height: 667 }, // Mobile
{ width: 1920, height: 1080 } // Desktop
],
penthouse: {
timeout: 30000,
renderWaitTime: 1000
}
}).then(({ css, html, uncritical }) => {
console.log('Critical CSS extracted:', css.length, 'bytes');
});
Pattern recommandé:
Gains:
Avant (blocking CSS):
├── FCP: 2.1s
└── LCP: 3.8s
Après (critical inline):
├── FCP: 0.8s (-62%)
└── LCP: 1.9s (-50%)
3.3 JavaScript Optimization
Code splitting par route:
// Webpack configuration
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// Vendor séparé
vendor: {
test: /[/]nodemodules[/]/,
name: 'vendors',
priority: 10
},
// Code commun
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true
}
}
}
},
// Dynamic imports
entry: {
main: './src/index.js'
}
};
// Application code avec lazy loading
class Router {
async loadRoute(path) {
const routes = {
'/': () => import('./pages/Home.js'),
'/blog': () => import('./pages/Blog.js'),
'/products': () => import('./pages/Products.js'),
'/admin': () => import(/ webpackChunkName: "admin" / './pages/Admin.js')
};
const loadPage = routes[path];
if (loadPage) {
const module = await loadPage();
module.default.render();
}
}
}
// Préchargement intelligent
class PrefetchManager {
constructor() {
this.observer = new IntersectionObserver(this.onIntersect.bind(this));
}
observe() {
document.querySelectorAll('a[data-prefetch]').forEach(link => {
this.observer.observe(link);
});
}
onIntersect(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const href = entry.target.getAttribute('href');
this.prefetch(href);
this.observer.unobserve(entry.target);
}
});
}
prefetch(href) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = href;
document.head.appendChild(link);
}
}
new PrefetchManager().observe();
Bundle analysis:
# Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer
# package.json
{
"scripts": {
"analyze": "webpack-bundle-analyzer dist/stats.json"
}
}
# Génération du rapport
npm run build -- --profile --json > dist/stats.json
npm run analyze
Résultats typiques:
Avant optimisation:
├── main.js: 450KB (gzipped: 120KB)
├── TBT: 520ms
└── TTI: 5.2s
Après code splitting:
├── main.js: 45KB (gzipped: 12KB)
├── vendor.js: 180KB (gzipped: 52KB) [cached]
├── routes/.js: 20-80KB chacun [lazy loaded]
├── TBT: 80ms (-85%)
└── TTI: 2.1s (-60%)
4. Monitoring en production
4.1 Real User Monitoring (RUM)
Implémentation custom:
// rum-monitor.js
class RUMMonitor {
constructor(config) {
this.config = config;
this.metrics = {};
this.init();
}
init() {
// Web Vitals
this.observeLCP();
this.observeFID();
this.observeCLS();
// Navigation Timing
this.observeNavigationTiming();
// Resource Timing
this.observeResourceTiming();
// Envoi périodique
setInterval(() => this.flush(), 30000);
// Envoi avant unload
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
}
observeLCP() {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.metrics.lcp = {
value: lastEntry.renderTime || lastEntry.loadTime,
element: lastEntry.element?.tagName,
url: lastEntry.url
};
}).observe({ entryTypes: ['largest-contentful-paint'] });
}
observeFID() {
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
this.metrics.fid = {
value: entry.processingStart - entry.startTime,
name: entry.name
};
});
}).observe({ entryTypes: ['first-input'], buffered: true });
}
observeCLS() {
let clsValue = 0;
let clsEntries = [];
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) {
clsValue += entry.value;
clsEntries.push({
value: entry.value,
sources: entry.sources?.map(s => s.node)
});
}
});
this.metrics.cls = {
value: clsValue,
entries: clsEntries
};
}).observe({ entryTypes: ['layout-shift'], buffered: true });
}
observeNavigationTiming() {
const perfData = performance.getEntriesByType('navigation')[0];
this.metrics.navigation = {
dns: perfData.domainLookupEnd - perfData.domainLookupStart,
tcp: perfData.connectEnd - perfData.connectStart,
ttfb: perfData.responseStart - perfData.requestStart,
download: perfData.responseEnd - perfData.responseStart,
domInteractive: perfData.domInteractive,
domComplete: perfData.domComplete,
loadComplete: perfData.loadEventEnd - perfData.loadEventStart
};
}
observeResourceTiming() {
const resources = performance.getEntriesByType('resource');
this.metrics.resources = {
count: resources.length,
totalSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
slowest: resources
.sort((a, b) => b.duration - a.duration)
.slice(0, 10)
.map(r => ({
name: r.name,
duration: r.duration,
size: r.transferSize
}))
};
}
flush() {
if (Object.keys(this.metrics).length === 0) return;
const payload = {
metrics: this.metrics,
context: {
url: location.href,
userAgent: navigator.userAgent,
connection: navigator.connection?.effectiveType,
timestamp: Date.now()
}
};
// Beacon API (non-blocking)
const success = navigator.sendBeacon(
this.config.endpoint,
JSON.stringify(payload)
);
if (success) {
this.metrics = {};
}
}
// API publique pour custom metrics
recordMetric(name, value, context = {}) {
this.metrics[name] = { value, context, timestamp: Date.now() };
}
}
// Initialisation
const rum = new RUMMonitor({
endpoint: '/api/rum-metrics'
});
// Utilisation custom
rum.recordMetric('checkout-duration', checkoutTime, {
items: cartItems.length,
total: orderTotal
});
Backend endpoint (Node.js/Express):
// Server-side metrics collection
const express = require('express');
const app = express();
app.post('/api/rum-metrics', express.json(), (req, res) => {
const { metrics, context } = req.body;
// Validation
if (!metrics || !context) {
return res.status(400).json({ error: 'Invalid payload' });
}
// Enrichissement
const enrichedMetrics = {
...metrics,
context: {
...context,
ip: req.ip,
geo: req.headers['cf-ipcountry'], // Cloudflare
server: process.env.SERVERID
}
};
// Envoi vers système d'analytics
sendToAnalytics(enrichedMetrics);
res.status(204).send();
});
function sendToAnalytics(data) {
// Exemple: envoi vers service externe
// (New Relic, Datadog, custom DB, etc.)
}
4.2 Alertes et dashboards
Configuration New Relic Browser:
// new-relic-config.js
window.NREUM || (NREUM = {});
NREUM.init = {
privacy: { cookiesenabled: true },
ajax: { denylist: ['bam.nr-data.net'] },
distributedtracing: { enabled: true }
};
// Custom attributes
newrelic.setCustomAttribute('userId', currentUser.id);
newrelic.setCustomAttribute('plan', currentUser.plan);
newrelic.setCustomAttribute('version', APPVERSION);
// Custom events
newrelic.addPageAction('purchase', {
total: orderTotal,
items: itemCount,
duration: checkoutDuration
});
// Alertes NRQL
const alertQueries = {
slowLCP: `
SELECT percentile(largestContentfulPaint, 75)
FROM PageViewTiming
WHERE appName = 'MyApp'
FACET countryCode
`,
highCLS: `
SELECT percentile(cumulativeLayoutShift, 95)
FROM PageViewTiming
WHERE cumulativeLayoutShift > 0.1
`,
errorRate: `
SELECT percentage(count(), WHERE error IS true)
FROM PageView
`
};
Dashboard Grafana (avec Prometheus):
# prometheus.yml
scrapeconfigs:
- jobname: 'web-vitals'
scrapeinterval: 30s
staticconfigs:
- targets: ['metrics-collector:9090']
# Queries Grafana
# Panel 1: LCP P75
query: histogramquantile(0.75, rate(lcpbucket[5m]))
# Panel 2: CLS P95
query: histogramquantile(0.95, rate(clsbucket[5m]))
# Panel 3: Error rate
query: rate(pageerrors_total[5m])
5. Cas pratique complet
Optimisation d'un site e-commerce
État initial (audit):
Lighthouse Score: 42/100
├── LCP: 5.8s (hero image 1.2MB)
├── FID: 420ms (scripts bloquants)
├── CLS: 0.65 (ads + images sans dimensions)
├── TTI: 8.2s
└── Total page weight: 4.8MB
Plan d'action:
Phase 1: Quick wins (1 semaine)
Résultats Phase 1:
Score: 42 → 68 (+26 points)
├── LCP: 5.8s → 2.9s (-50%)
├── CLS: 0.65 → 0.12 (-81%)
└── Page weight: 4.8MB → 1.2MB (-75%)
Phase 2: Optimisations avancées (2 semaines)
// 1. Code splitting
const ProductPage = lazy(() => import('./pages/Product'));
const CheckoutPage = lazy(() => import('./pages/Checkout'));
// 2. Service Worker pour caching
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then(cache => {
return cache.addAll([
'/',
'/css/critical.css',
'/js/app.js',
'/fonts/main.woff2'
]);
})
);
});
// 3. Resource hints dynamiques
document.addEventListener('mouseover', (e) => {
if (e.target.matches('a[href^="/product/"]')) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = e.target.href;
document.head.appendChild(link);
}
}, { once: true });
// 4. Image lazy loading observateur
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
imageObserver.unobserve(img);
}
});
}, { rootMargin: '50px' });
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
Résultats Phase 2:
Score: 68 → 91 (+23 points)
├── LCP: 2.9s → 1.6s (-45%)
├── FID: 420ms → 35ms (-92%)
├── TTI: 8.2s → 2.8s (-66%)
└── Bounce rate: -28%
Phase 3: Monitoring et fine-tuning (ongoing)
// RUM monitoring setup
const rum = new RUMMonitor({
endpoint: '/api/metrics',
sampleRate: 0.1 // 10% des utilisateurs
});
// A/B testing des optimisations
if (Math.random() < 0.5) {
// Groupe A: preload hero image
const preload = document.createElement('link');
preload.rel = 'preload';
preload.as = 'image';
preload.href = '/images/hero.webp';
document.head.appendChild(preload);
rum.recordMetric('experiment', 'preload-hero', { group: 'A' });
} else {
// Groupe B: contrôle
rum.recordMetric('experiment', 'preload-hero', { group: 'B' });
}
Résultats finaux:
Lighthouse Score: 91/100
├── LCP: 1.6s (Excellent ✓)
├── FID: 35ms (Excellent ✓)
├── CLS: 0.04 (Excellent ✓)
├── TTI: 2.8s
├── Page weight: 850KB
└── Business impact:
├── Conversion rate: +34%
├── Bounce rate: -28%
├── Average session: +41%
└── SEO ranking: +12 positions
6. Checklist d'optimisation
Audit initial
□ Lighthouse audit (mobile + desktop)
□ WebPageTest (3G/4G/Cable)
□ Chrome DevTools Performance profiling
□ Real User Monitoring baseline
Images
□ Formats modernes (WebP/AVIF avec fallback)
□ Dimensions explicites (width/height)
□ Lazy loading (sauf above-the-fold)
□ Responsive images (srcset/sizes)
□ CDN avec optimisation automatique
□ Compression appropriée (80-85% quality)
CSS
□ Critical CSS inline (< 14KB)
□ Non-critical async
□ Minification + purge CSS inutilisé
□ Font-display: swap
□ Preload fonts critiques
JavaScript
□ Code splitting par route
□ Defer/async scripts non-critiques
□ Tree shaking activé
□ Minification + compression
□ Polyfills conditionnels uniquement
□ Web Workers pour tâches lourdes
Network
□ HTTP/2 ou HTTP/3
□ Compression Brotli/Gzip
□ CDN pour assets statiques
□ Resource hints (preconnect/dns-prefetch)
□ Cache headers appropriés
Monitoring
□ RUM en production
□ Lighthouse CI dans pipeline
□ Alertes sur dégradation métriques
□ Dashboards temps réel
Ressources complémentaires
Documentation officielle
Outils
Bibliothèques
# Web Vitals library
npm install web-vitals
# Lighthouse CI
npm install -g @lhci/cli
# Bundle analysis
npm install --save-dev webpack-bundle-analyzer
Conclusion
Les Core Web Vitals sont devenus incontournables pour:
Prochaines étapes recommandées:
Les optimisations présentées sont toutes testées en production avec des gains mesurés. Priorisez selon votre contexte et mesurez l'impact réel sur vos utilisateurs.