Sécuriser une Application Web : Headers, CORS et CSP
Introduction : La Première Ligne de Défense Les headers HTTP de sécurité constituent la première…
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.
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.
Définition: Temps nécessaire pour afficher le plus grand élément de contenu visible.
Excellent : LCP ≤ 2.5s
À améliorer : 2.5s < LCP ≤ 4.0s
Mauvais : LCP > 4.0s
Avant optimisation (LCP: 4.2s):
Après optimisation (LCP: 1.8s):
Gains mesurés:
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.
FID:
├── Excellent : ≤ 100ms
├── À améliorer : 100-300ms
└── Mauvais : > 300ms
INP (nouveau):
├── Excellent : ≤ 200ms
├── À améliorer : 200-500ms
└── Mauvais : > 500ms
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:
Définition: Mesure l’instabilité visuelle du contenu pendant le chargement.
Excellent : CLS ≤ 0.1
À améliorer : 0.1 < CLS ≤ 0.25
Mauvais : CLS > 0.25
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:
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% │
└────────────────────┴──────────┴──────────┴──────────┘
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)
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();
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%)
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%)
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%)
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.)
}
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])
É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:
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%)
// 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%
// 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
□ Lighthouse audit (mobile + desktop)
□ WebPageTest (3G/4G/Cable)
□ Chrome DevTools Performance profiling
□ Real User Monitoring baseline
□ 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)
□ Critical CSS inline (< 14KB)
□ Non-critical async
□ Minification + purge CSS inutilisé
□ Font-display: swap
□ Preload fonts critiques
□ Code splitting par route
□ Defer/async scripts non-critiques
□ Tree shaking activé
□ Minification + compression
□ Polyfills conditionnels uniquement
□ Web Workers pour tâches lourdes
□ HTTP/2 ou HTTP/3
□ Compression Brotli/Gzip
□ CDN pour assets statiques
□ Resource hints (preconnect/dns-prefetch)
□ Cache headers appropriés
□ RUM en production
□ Lighthouse CI dans pipeline
□ Alertes sur dégradation métriques
□ Dashboards temps réel
# Web Vitals library
npm install web-vitals
# Lighthouse CI
npm install -g @lhci/cli
# Bundle analysis
npm install --save-dev webpack-bundle-analyzer
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.
Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.