Un site e-commerce de 12 000 fiches produits, chacune avec 4 à 6 visuels en JPEG non optimisé. Poids moyen par page : 4,2 Mo. LCP à 5,8 secondes sur mobile 4G. Après migration vers WebP/AVIF avec <picture>, lazy loading natif et srcset correctement dimensionné : 1,1 Mo par page, LCP à 2,1 secondes. Le trafic organique mobile a progressé de 23 % en 8 semaines — sans toucher au contenu texte ni aux backlinks.
Les images représentent en moyenne 50 % du poids total d'une page web selon les données du HTTP Archive. C'est le levier de performance le plus sous-exploité en SEO technique. Pas parce que les équipes ignorent le problème, mais parce que la chaîne complète — encodage, négociation de format, dimensionnement responsive, chargement différé, hints au navigateur — est rarement maîtrisée de bout en bout.
WebP et AVIF : choisir le bon format au bon moment
Le rapport compression/qualité en chiffres
JPEG a 30 ans. Il reste un format de fallback solide, mais ses successeurs offrent des gains de poids significatifs à qualité perceptuelle équivalente :
- WebP (Google, 2010) : compression lossy 25 à 34 % plus efficace que JPEG selon les benchmarks internes de Google publiés sur web.dev. Support navigateur quasi universel (97 %+ sur caniuse.com en 2026).
- AVIF (Alliance for Open Media, 2019) : basé sur le codec vidéo AV1. Compression lossy encore 20 à 30 % meilleure que WebP sur les images photographiques. Support solide sur Chrome, Firefox, Safari 16.4+.
Le piège classique : convertir aveuglément toutes les images en AVIF. Trois cas où ce n'est pas le bon choix :
- Images avec du texte incrusté ou des lignes nettes — AVIF lossy produit parfois des artefacts de flou visibles sur les contours. WebP lossless ou PNG restent préférables.
- Temps d'encodage — AVIF est 10 à 50x plus lent à encoder que WebP. Sur un pipeline de build qui traite 50 000 images, ça se compte en heures de CI.
- Images animées — WebP animé est mieux supporté et plus léger qu'AVIF séquentiel dans la plupart des cas.
Pipeline de conversion en CLI
Sharp (via Node.js) et libvips sont les outils de référence en production. Voici un script de conversion batch réaliste :
#!/bin/bash
# Conversion batch JPEG → WebP + AVIF avec contrôle qualité
# Requiert: cwebp (libwebp), avifenc (libavif)
INPUT_DIR="./images/originals"
OUTPUT_DIR="./images/optimized"
mkdir -p "$OUTPUT_DIR/webp" "$OUTPUT_DIR/avif"
for img in "$INPUT_DIR"/*.jpg; do
filename=$(basename "$img" .jpg)
# WebP : qualité 80 = bon compromis visuel/poids pour du e-commerce
cwebp -q 80 -m 6 -af "$img" -o "$OUTPUT_DIR/webp/${filename}.webp"
# AVIF : qualité 30 (échelle inversée, 0=meilleur), speed 4 (compromis vitesse/qualité)
avifenc "$img" "$OUTPUT_DIR/avif/${filename}.avif" \
--min 20 --max 35 --speed 4 --yuv 420
echo "✓ $filename → WebP $(stat -f%z "$OUTPUT_DIR/webp/${filename}.webp") bytes, AVIF $(stat -f%z "$OUTPUT_DIR/avif/${filename}.avif") bytes"
done
Sur le site e-commerce mentionné plus haut (48 000 images produit), ce pipeline a réduit le stockage total de 38 Go (JPEG) à 14 Go (WebP) et 9,8 Go (AVIF). Le temps d'encodage AVIF : 4h12 sur un runner CI 8 vCPU. WebP : 22 minutes.
Pour les stacks Node.js, Sharp offre une API plus intégrable dans un pipeline de build :
// sharp-convert.ts — Conversion avec Sharp pour un pipeline Next.js / custom
import sharp from 'sharp';
import { readdir } from 'fs/promises';
import path from 'path';
interface ConversionResult {
original: number;
webp: number;
avif: number;
savings: string;
}
async function convertImage(inputPath: string, outputDir: string): Promise<ConversionResult> {
const image = sharp(inputPath);
const metadata = await image.metadata();
const filename = path.basename(inputPath, path.extname(inputPath));
const originalSize = metadata.size ?? 0;
// Resize si > 2000px de large (inutile pour du web)
const pipeline = metadata.width && metadata.width > 2000
? image.resize({ width: 2000, withoutEnlargement: true })
: image;
const [webpBuf, avifBuf] = await Promise.all([
pipeline.clone().webp({ quality: 80, effort: 6 }).toBuffer(),
pipeline.clone().avif({ quality: 45, effort: 4, chromaSubsampling: '4:2:0' }).toBuffer(),
]);
await Promise.all([
sharp(webpBuf).toFile(path.join(outputDir, `${filename}.webp`)),
sharp(avifBuf).toFile(path.join(outputDir, `${filename}.avif`)),
]);
return {
original: originalSize,
webp: webpBuf.length,
avif: avifBuf.length,
savings: `WebP: -${Math.round((1 - webpBuf.length / originalSize) * 100)}%, AVIF: -${Math.round((1 - avifBuf.length / originalSize) * 100)}%`,
};
}
Point important : ne supprimez pas les originaux JPEG. Ils servent de fallback pour les clients qui ne supportent ni WebP ni AVIF (certains bots, lecteurs RSS, outils d'accessibilité anciens). Et ils servent de source pour ré-encoder si les codecs évoluent.
Content negotiation : servir le bon format au bon client
Générer WebP et AVIF ne sert à rien si le serveur envoie toujours le JPEG. Deux approches en production.
Approche 1 : <picture> en HTML (recommandée)
L'élément <picture> avec des <source> ordonnés donne au navigateur le contrôle total. C'est la méthode la plus fiable et la plus lisible pour Googlebot :
<picture>
<!-- AVIF en premier : le navigateur prend le premier format qu'il supporte -->
<source
type="image/avif"
srcset="/images/product-42.avif 1x, /images/[email protected] 2x"
>
<source
type="image/webp"
srcset="/images/product-42.webp 1x, /images/[email protected] 2x"
>
<!-- Fallback JPEG — toujours présent -->
<img
src="/images/product-42.jpg"
alt="Chaussure de trail Salomon Speedcross 6 — vue latérale"
width="800"
height="600"
loading="lazy"
decoding="async"
fetchpriority="low"
>
</picture>
Googlebot (basé sur Chrome 131+ en 2026) supporte AVIF et WebP. Il indexera l'image via Google Images en utilisant le format qu'il télécharge réellement. Le alt de l'élément <img> de fallback est celui qui compte pour le SEO — les <source> n'ont pas d'attribut alt.
Approche 2 : négociation côté serveur via Accept header
Le navigateur envoie Accept: image/avif,image/webp,image/apng,image/* dans ses requêtes. Le serveur peut utiliser ce header pour servir le bon fichier sur la même URL.
Config Nginx :
# /etc/nginx/snippets/image-negotiation.conf
# Négociation de format image basée sur le header Accept
map $http_accept $webp_suffix {
default "";
"~*avif" ".avif";
"~*webp" ".webp";
}
# Dans le bloc server/location :
location ~* ^(/images/.+)\.(jpe?g|png)$ {
# Cherche d'abord AVIF, puis WebP, puis l'original
set $img_path $1;
# Essai AVIF
try_files $img_path.avif $img_path.webp $uri =404;
# Vary header OBLIGATOIRE pour le cache CDN
add_header Vary Accept;
add_header Cache-Control "public, max-age=31536000, immutable";
}
L'avantage : une seule URL par image, pas de changement HTML. L'inconvénient majeur : le header Vary: Accept complique le caching CDN. Cloudflare, Fastly et CloudFront gèrent ça correctement, mais certains CDN moins sophistiqués ne key-ent pas leur cache sur Accept, ce qui peut servir du WebP à un client qui ne le supporte pas (ou l'inverse).
Pour les gros sites, l'approche <picture> est plus robuste. Elle fonctionne sans configuration serveur spéciale et le caching est trivial puisque chaque format a sa propre URL.
Lazy loading : natif, Intersection Observer, et les pièges SEO
Le lazy loading natif suffit (presque toujours)
loading="lazy" sur <img> est supporté par tous les navigateurs modernes. Le navigateur décide lui-même du seuil de chargement en fonction de la vitesse de connexion et de la distance au viewport.
La règle absolue : ne jamais lazy-loader l'image LCP. Si votre hero image ou votre visuel produit principal est l'élément le plus large peint dans le viewport, loading="lazy" retardera son affichage et détruira votre score LCP.
<!-- Image above-the-fold = LCP candidate → PAS de lazy loading -->
<img
src="/images/hero-collection-ete.webp"
alt="Collection été 2026 — robes et accessoires"
width="1200"
height="630"
loading="eager"
fetchpriority="high"
decoding="async"
>
<!-- Images below-the-fold = lazy loading natif -->
<img
src="/images/product-thumbnail-117.webp"
alt="Robe midi lin naturel — coloris sable"
width="400"
height="400"
loading="lazy"
decoding="async"
>
L'attribut fetchpriority="high" est le complément indispensable pour l'image LCP. Il signale au navigateur de prioriser cette requête dans la file d'attente réseau, avant les scripts et les feuilles de style non critiques. Documenté sur web.dev/fetch-priority.
Googlebot et lazy loading : ce qui a changé
Googlebot exécute JavaScript et scrolle la page. Il déclenchera donc les images en loading="lazy" et celles chargées via Intersection Observer. Mais avec deux nuances :
-
Le crawl budget a un coût. Chaque image lazy-loadée nécessite que Googlebot exécute le rendu JavaScript pour la découvrir. Sur un site de 15 000 pages avec 6 images par page, ça fait 90 000 images que Googlebot doit "scroller" pour voir. Comparez avec des images en
loading="eager"visibles dans le HTML initial, découvertes au premier crawl sans rendu. Pour les SPA où le HTML initial est vide, le problème est encore plus aigu. -
Les implémentations JavaScript custom cassent régulièrement. Les librairies qui utilisent
data-srcau lieu desrcet swap au scroll produisent un<img src="placeholder.gif">dans le HTML brut. Si le JavaScript casse (erreur de build, CDN down), Googlebot indexe le placeholder. C'est un scénario que des outils de monitoring comme Seogard détectent en comparant le HTML statique au DOM rendu.
Recommandation claire : utilisez loading="lazy" natif. Abandonnez les librairies JS de lazy loading sauf besoin très spécifique (ex : effet de blur-up progressif qui nécessite un placeholder basse résolution).
Responsive images : srcset et sizes sans approximation
Servir une image de 2400px de large à un écran mobile de 375px est un gaspillage de bande passante pur. srcset avec l'attribut sizes permet au navigateur de choisir la bonne dimension avant même de télécharger l'image.
La syntaxe correcte (et ce que 80 % des sites font mal)
<img
src="/images/product-42-800.jpg"
srcset="
/images/product-42-400.webp 400w,
/images/product-42-800.webp 800w,
/images/product-42-1200.webp 1200w,
/images/product-42-1600.webp 1600w
"
sizes="
(max-width: 640px) 100vw,
(max-width: 1024px) 50vw,
33vw
"
alt="Chaussure trail Salomon Speedcross 6 — vue de profil"
width="800"
height="600"
loading="lazy"
decoding="async"
>
L'erreur la plus fréquente : omettre sizes ou mettre sizes="100vw" par défaut. Sans sizes correct, le navigateur suppose que l'image occupe 100 % du viewport et télécharge la variante la plus large. Sur une grille produit en 3 colonnes sur desktop, chaque image n'occupe que ~33 % du viewport. Le navigateur pourrait charger la variante 400w au lieu de 1600w — soit 4x moins de données.
Générer les variantes automatiquement
Sur un site à 12 000 fiches produits, personne ne redimensionne manuellement. Un script de build s'intègre dans le pipeline CI :
// generate-responsive.ts
import sharp from 'sharp';
import path from 'path';
const WIDTHS = [400, 800, 1200, 1600];
const FORMATS: Array<{ ext: string; options: sharp.WebpOptions | sharp.AvifOptions; method: 'webp' | 'avif' }> = [
{ ext: 'webp', options: { quality: 80 }, method: 'webp' },
{ ext: 'avif', options: { quality: 45 }, method: 'avif' },
];
async function generateVariants(inputPath: string, outputDir: string): Promise<void> {
const filename = path.basename(inputPath, path.extname(inputPath));
const image = sharp(inputPath);
const meta = await image.metadata();
const tasks: Promise<sharp.OutputInfo>[] = [];
for (const width of WIDTHS) {
// Ne pas upscaler
if (meta.width && width > meta.width) continue;
for (const format of FORMATS) {
const outPath = path.join(outputDir, `${filename}-${width}.${format.ext}`);
tasks.push(
image
.clone()
.resize({ width, withoutEnlargement: true })
[format.method](format.options as any)
.toFile(outPath)
);
}
}
await Promise.all(tasks);
// 4 widths × 2 formats = 8 variantes par image source
}
Sur le site e-commerce de référence, ce pipeline génère 8 variantes par image source. 48 000 originaux × 8 = 384 000 fichiers. Poids total : ~52 Go. Ça paraît beaucoup, mais sur un CDN avec stockage object (S3, GCS, R2), le coût est négligeable et le gain en performance est massif.
width, height et CLS : les attributs que tout le monde oublie
Les attributs width et height sur <img> ne sont pas cosmétiques. Le navigateur les utilise pour calculer l'aspect ratio avant le chargement de l'image et réserver l'espace dans le layout. Sans eux, le navigateur ne connaît pas la taille de l'image tant qu'elle n'est pas téléchargée, ce qui provoque un décalage de layout (CLS) au moment où l'image apparaît.
<!-- MAUVAIS : pas de dimensions → CLS garanti -->
<img src="/images/product.webp" alt="Produit">
<!-- BON : dimensions explicites + CSS responsive -->
<img
src="/images/product.webp"
alt="Produit"
width="800"
height="600"
style="max-width: 100%; height: auto;"
>
Le CSS max-width: 100%; height: auto; (ou via aspect-ratio en CSS) permet à l'image de rester responsive tout en conservant la réservation d'espace. Les navigateurs modernes calculent automatiquement l'aspect-ratio à partir de width et height — c'est documenté dans la spec HTML.
Cas pernicieux : les CMS qui génèrent des <img> sans width/height. WordPress le fait correctement depuis la 5.5, mais les thèmes custom et les builders visuels cassent souvent ce mécanisme. Vérifiez avec Chrome DevTools > Performance > Layout Shifts pour identifier les images responsables de CLS.
Preload, CDN et cache : la couche infrastructure
Preload de l'image LCP
L'image LCP ne doit pas attendre que le parser HTML atteigne la balise <img> pour commencer à se charger. Un <link rel="preload"> dans le <head> lance le téléchargement immédiatement :
<head>
<!-- Preload de l'image LCP en AVIF avec fallback WebP -->
<link
rel="preload"
as="image"
href="/images/hero-collection-ete.avif"
type="image/avif"
fetchpriority="high"
imagesrcset="
/images/hero-collection-ete-800.avif 800w,
/images/hero-collection-ete-1200.avif 1200w,
/images/hero-collection-ete-1600.avif 1600w
"
imagesizes="100vw"
>
</head>
Attention : ne preloadez qu'une seule image par page (l'image LCP). Preloader plusieurs images surcharge la bande passante et annule le bénéfice. Chrome DevTools > Network > Initiator vous montre si le preload a effectivement accéléré le chargement.
Headers de cache agressifs
Les images ne changent quasiment jamais. Utilisez un cache immutable avec fingerprinting dans le nom de fichier :
location /images/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary Accept;
# Compression inutile pour les images — elles sont déjà compressées
gzip off;
brotli off;
}
Le fingerprinting (product-42-800.a3f8c2.webp) permet de bust le cache sans invalider les CDN manuellement. Tout bundler moderne (Vite, webpack, Next.js) le supporte nativement.
Scénario complet : migration d'un e-commerce de 15 000 pages
Voici le déroulé réel d'une optimisation images sur un site e-commerce mode (15 000 URLs indexées, 62 000 images produit).
Audit initial
- Screaming Frog : crawl de 15 000 pages. Export des
<img>: 73 % en JPEG, 22 % en PNG (dont des photos produit qui n'ont aucune raison d'être en PNG), 5 % en WebP (ajouté partiellement par un dev 2 ans plus tôt). - Chrome DevTools > Lighthouse sur 20 pages échantillon : LCP moyen 4,7s, poids images moyen 3,8 Mo par page.
- Search Console > Core Web Vitals : 68 % des URLs mobiles en "Needs Improvement" ou "Poor" pour LCP.
- PageSpeed Insights : recommandation "Serve images in next-gen formats" sur 100 % des pages testées.
Exécution (3 semaines)
Semaine 1 — Pipeline de conversion :
- Script Sharp intégré dans le pipeline CI/CD GitHub Actions.
- Conversion des 62 000 images en 4 largeurs × 2 formats (WebP + AVIF) + conservation des JPEG originaux.
- Temps de build initial : 6h40 sur un runner 16 vCPU. Mis en cache — les builds suivants ne convertissent que les nouvelles images.
Semaine 2 — Modification des templates :
- Remplacement des
<img src="product.jpg">par des<picture>avecsrcsetetsizescorrects. - Ajout systématique de
widthetheight(récupérés via Sharpmetadata()). - Image LCP (hero en haut de page catégorie) :
loading="eager"+fetchpriority="high"+<link rel="preload">. - Toutes les autres images :
loading="lazy"+decoding="async".
Semaine 3 — Infrastructure :
- Configuration CDN (Cloudflare) pour cacher les variantes avec
Vary: Accept. - Headers
Cache-Control: immutableavec fingerprinting activé via le build. - Suppression d'une librairie JS de lazy loading (lazysizes) qui injectait
data-src— remplacée parloading="lazy"natif.
Résultats à 8 semaines
- Poids médian par page : 3,8 Mo → 0,95 Mo (-75 %)
- LCP mobile (p75) : 4,7s → 1,9s. Passage de "Needs Improvement" à "Good" sur 91 % des URLs dans Search Console.
- CLS mobile : amélioration collatérale de 0,18 → 0,04 grâce aux attributs
width/heightajoutés. - Trafic organique mobile : +23 % à 8 semaines (données Search Console, filtré sur les pages non modifiées en contenu pour isoler l'effet performance).
- Crawl budget : Google a augmenté la fréquence de crawl de 15 % selon les logs serveur (plus de pages crawlées par jour). Le temps de téléchargement moyen par page vu par Googlebot est passé de 2,3s à 0,8s — un facteur que Google utilise pour moduler le crawl rate.
L'impact sur le LCP et le CLS se traduit directement en amélioration des Core Web Vitals. Ce n'est pas un facteur de ranking isolé, mais combiné à un contenu déjà solide, c'est le genre de levier qui fait basculer des positions 5-8 vers le top 3 sur des requêtes commerciales compétitives.
Audit continu et détection de régression
L'optimisation images n'est pas un one-shot. Les régressions arrivent constamment :
- Un développeur push une image PNG de 4 Mo uploadée directement depuis Figma sans compression.
- Un changement de template supprime le
<picture>et remet un<img src="product.jpg">simple. - Une mise à jour du CMS réinitialise les attributs
width/height. - Le CDN invalide son cache et sert temporairement les JPEG originaux.
Screaming Frog en crawl ponctuel ne suffit pas. Vous avez besoin d'un monitoring continu qui alerte quand le poids moyen des pages dépasse un seuil, quand des images perdent leurs attributs de dimensions, ou quand le format servi change. Seogard détecte ce type de régression technique automatiquement en comparant les snapshots de rendu dans le temps — un <picture> qui disparaît ou un loading="lazy" supprimé sur l'image LCP déclenche une alerte avant que l'impact SEO ne se matérialise.
L'optimisation des images est le levier de performance le plus rentable en ratio effort/impact. Les formats modernes, le lazy loading natif et le responsive correct ne sont pas des optimisations de luxe — c'est la baseline technique de tout site qui prend le SEO au sérieux. Mettez en place le pipeline une fois, surveillez les régressions en continu, et vous ne toucherez plus jamais au sujet sauf pour encoder en JPEG XL le jour où les navigateurs le supporteront tous.