← SlowSite

03 — Guide de résolution

Ce document ne vous donne pas la liste des problèmes de ce site : c'est à vous de la dresser. Il vous donne, pour chaque famille de problème, la technique de correction.


A. Images

Les images représentent presque toujours l'essentiel du poids d'une page. C'est le premier chantier.

A1. Contraindre la largeur d'affichage

C'est la première chose à vérifier, et elle ne demande aucun outil : la page défile-t-elle horizontalement ? Si oui, quelque chose dépasse.

Une image sans contrainte CSS s'affiche à sa largeur native. Un fichier de 4000 px de large produit une page de 4000 px, quel que soit l'écran. Sur un téléphone, le visiteur voit un coin de l'image et doit faire glisser la page latéralement pour lire le reste.

Comment vérifier : réduisez la fenêtre, ou activez le mode appareil mobile des DevTools (Ctrl+Maj+M). Une barre de défilement horizontale est toujours un défaut.

Correction :

.mon-conteneur img{display:block;width:100%;height:auto}

width:100% borne l'image à la largeur de son conteneur, height:auto préserve ses proportions. Si vous voulez qu'une petite image ne soit pas étirée au-delà de sa taille réelle, utilisez max-width:100% à la place.

Attention : cela corrige l'affichage, pas le poids. Une image de 4000 px ramenée à 800 px à l'écran pèse toujours 4000 px dans le réseau. Les deux problèmes sont distincts et se corrigent séparément — voir le point suivant.

A2. Dimensions réelles vs dimensions d'affichage

Une image affichée dans une grille de 380 px de large n'a aucune raison d'être stockée en 4000 px. Sur un écran classique, 380 px d'affichage justifient 760 px de source (densité ×2), pas davantage.

Comment vérifier : dans l'onglet Réseau, survolez une image. L'infobulle affiche Dimensions intrinsèques et Taille rendue. Un rapport de 5 ou 10 entre les deux est une erreur.

Correction : redimensionnez la source. Un outil hors ligne (GIMP, Squoosh.app, ImageMagick) suffit.

# exemple avec ImageMagick
magick img1.jpg -resize 800x -quality 78 img1-800.jpg

A3. Format

Le JPEG a plus de vingt-cinq ans. À qualité visuelle égale :

FormatGain typique vs JPEGSupport
WebP−30 % environuniversel aujourd'hui
AVIF−50 % environtrès large, mais encodage plus lent

Pour conserver un repli, utilisez <picture> :

<picture>
  <source srcset="img/img1.avif" type="image/avif">
  <source srcset="img/img1.webp" type="image/webp">
  <img src="img/img1.jpg" alt="Image 1" width="760" height="507">
</picture>

Pour un logo, une icône ou un pictogramme : SVG, qui pèse quelques centaines d'octets et reste net à toute taille. Un bitmap là où un SVG conviendrait, c'est du poids payé pour rien — et la question se pose pour toutes les icônes d'une page, favicon compris.

A4. Images servies plusieurs fois

Le navigateur identifie une ressource par son URL complète, query string comprise. img/photo.jpg et img/photo.jpg?v=2 sont deux ressources différentes : elles sont téléchargées deux fois, mises en cache deux fois.

Comment vérifier : dans l'onglet Réseau, filtrez sur le nom du fichier. Si deux lignes apparaissent, vous payez deux fois.

A5. Images téléchargées sans jamais être vues

Une image de fond CSS est téléchargée dès que la règle s'applique à un élément présent dans le DOM — même si elle est invisible. Une opacité très faible, un élément recouvert, un visibility:hidden : le téléchargement a lieu quand même. (Seul display:none sur l'élément porteur l'évite, et c'est un comportement à ne pas prendre pour acquis.)

Comment vérifier : listez les images de l'onglet Réseau, et pointez pour chacune l'endroit exact où elle apparaît à l'écran. Celle que vous n'arrivez pas à situer est votre problème.

Correction : si l'effet visuel est négligeable, supprimez la règle — mais vérifiez d'abord qu'il est bien négligeable (la contrainte d'iso-rendu s'applique). Si l'effet est voulu, une image de quelques dizaines de kilo-octets suffit largement pour une texture : une texture n'a pas besoin d'être nette.

A6. width / height et le CLS

Sans dimensions déclarées, le navigateur réserve une hauteur de zéro, puis pousse tout le contenu vers le bas quand l'image arrive. C'est du CLS, et c'est la chose la plus désagréable qui puisse arriver à un lecteur.

<img src="img/img1.jpg" alt="Image 1" width="760" height="507">

Les attributs donnent le ratio, pas la taille d'affichage : votre CSS (width:100%; height:auto) continue de commander. Il n'y a aucune raison de s'en priver.

A7. Chargement différé

<!-- images sous la ligne de flottaison -->
<img src="…" loading="lazy" decoding="async" width="760" height="507">

<!-- l'image LCP, au contraire : jamais lazy, et prioritaire -->
<img src="…" fetchpriority="high" width="760" height="507">

Attention : mettre loading="lazy" sur l'image principale dégrade le LCP. La règle est : tout ce qui est visible d'emblée reste en chargement immédiat.

A8. srcset

Pour servir une image adaptée à chaque écran :

<img src="img/img1-760.jpg"
     srcset="img/img1-380.jpg 380w, img/img1-760.jpg 760w, img/img1-1200.jpg 1200w"
     sizes="(max-width:560px) 100vw, (max-width:900px) 50vw, 33vw"
     alt="Image 1" width="760" height="507">

sizes doit décrire la largeur d'affichage réelle selon le viewport. Relisez les media queries du CSS pour la déterminer.


B. Chaîne critique et réseau

B1. Scripts bloquants dans le <head>

<script src="…"></script>

Cette forme arrête l'analyse du HTML : téléchargement, puis exécution complète, puis reprise. Tant que ça dure, rien ne s'affiche.

FormeTéléchargementExécutionOrdre garanti
<script>bloque le parsingimmédiateoui
<script defer>en parallèleaprès le parsingoui
<script async>en parallèledès que prêtnon

Règle : defer par défaut. async seulement pour un script totalement indépendant. Sans attribut, seulement si vous pouvez justifier pourquoi.

Si plusieurs scripts dépendent les uns des autres, defer préserve leur ordre — c'est précisément à ça qu'il sert.

B2. @import en CSS

/* a.css */
@import url('b.css');   /* b.css n'est découvert qu'après téléchargement de a.css */

Chaque niveau d'@import ajoute un aller-retour en série, et le CSS bloque le rendu. Une chaîne de trois fichiers, c'est trois allers-retours avant le premier pixel.

Comment vérifier : dans la cascade de l'onglet Réseau, les fichiers CSS apparaissent en escalier au lieu d'être alignés.

Correction : remplacez par des <link> parallèles dans le HTML, ou fusionnez les fichiers.

<link rel="stylesheet" href="css/a.css">
<link rel="stylesheet" href="css/b.css">

B3. Polices web

Trois problèmes distincts, souvent cumulés.

Le domaine. Les polices Google sont sur fonts.gstatic.com, distinct de fonts.googleapis.com qui sert la feuille de style. Chaque nouveau domaine coûte une résolution DNS, une poignée de main TCP et une négociation TLS avant le moindre octet utile.

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Le font-display. display=block masque le texte jusqu'à l'arrivée de la police (FOIT : l'utilisateur regarde une page vide). display=swap affiche immédiatement avec une police système, puis remplace.

https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap

Les graisses inutilisées. Chaque graisse (300, 400, 600, 700) est un fichier distinct. Comptez celles que votre CSS utilise vraiment.

Le preload de police. Il est utile, mais une police est toujours récupérée en mode anonyme CORS. Un preload sans crossorigin crée une entrée de cache qui ne correspond pas à celle de la vraie requête : la police est téléchargée deux fois, et le preload fait l'inverse de ce qu'on attend de lui.

<!-- faux : deux téléchargements -->
<link rel="preload" as="font" type="font/woff2" href="…woff2">

<!-- correct -->
<link rel="preload" as="font" type="font/woff2" href="…woff2" crossorigin>

Chrome le signale dans la console : « A preload for … is found, but is not used because the request credentials mode does not match. » Lisez vos avertissements de console.

L'alternative : héberger la police avec le site. Un domaine de moins, plus de dépendance externe, et le contrôle total du font-display.

B4. preload mal employé

preload ne rend rien plus rapide : il change des priorités. Précharger une ressource non critique lui fait voler de la bande passante à la ressource critique. Précharger une image située tout en bas de page retarde celle qui détermine le LCP.

Règle : on ne précharge que ce qui est (a) nécessaire au premier rendu et (b) découvert tardivement par le navigateur. Une image déjà présente dans un <img> du HTML est découverte immédiatement : la précharger n'apporte rien.

B5. Requêtes en échec

Un 404 coûte un aller-retour complet. Un script 404 en position bloquante retarde le rendu de toute la page pour rien.

Comment vérifier : onglet Réseau, filtre sur les statuts ≠ 200. La console les signale aussi.

Correction : supprimer la référence, ou rétablir le fichier s'il est réellement utile. Ne laissez jamais un 404 en production.

B6. Indices de connexion inutiles

dns-prefetch et preconnect vers un domaine que la page ne contacte jamais consomment des ressources sans bénéfice. Vérifiez que chaque domaine annoncé apparaît bien dans l'onglet Réseau.


C. JavaScript

C1. Repérer le code coûteux sans le lire

Panneau Performance, ralentissement CPU ×4, enregistrement long, puis onglet Bottom-Up trié par Self Time. Les premières lignes nomment la fonction et son fichier. Vous savez alors où regarder — y compris dans un fichier minifié, où les numéros de ligne restent exploitables via le pretty-print ({} en bas du panneau Sources).

C2. Layout thrashing (reflow forcé)

Le navigateur regroupe les modifications de style et ne recalcule la mise en page qu'une fois, à la fin. Sauf si vous lisez une propriété géométrique juste après avoir écrit : il est alors obligé de tout recalculer sur-le-champ pour vous répondre.

Propriétés qui forcent un recalcul à la lecture : offsetTop, offsetHeight, offsetWidth, clientHeight, scrollTop, getBoundingClientRect(), getComputedStyle().

// ❌ lecture / écriture alternées : un reflow par tour de boucle
for (const el of items) {
  el.style.height = el.offsetHeight + 'px';
}

// ✅ toutes les lectures, puis toutes les écritures : un seul reflow
const heights = items.map(el => el.offsetHeight);
items.forEach((el, i) => { el.style.height = heights[i] + 'px'; });

Chrome vous prévient : « Forced reflow while executing JavaScript » dans la console et sur les blocs du panneau Performance. Ce message est une adresse, pas une fatalité.

C3. Complexité quadratique

Un querySelectorAll à l'intérieur d'une boucle qui parcourt déjà le résultat d'un querySelectorAll donne un coût en n². Avec 6 éléments c'est indolore, avec 300 c'est 90 000 opérations — par événement.

// ❌
for (const card of cards) {
  const all = document.querySelectorAll('.card');   // à chaque tour
  …
}

// ✅ hors de la boucle, une fois pour toutes
const all = document.querySelectorAll('.card');
for (const card of cards) { … }

Plus généralement : tout ce qui ne dépend pas de l'itération sort de la boucle.

C4. Gestionnaires de scroll et de resize

Ces événements se déclenchent des dizaines de fois par seconde. Y placer du travail lourd garantit le saccadement.

Solution 1 — la bonne : IntersectionObserver. Pour « faire quelque chose quand un élément entre dans le viewport », c'est l'outil prévu. Le navigateur fait le calcul lui-même, hors du fil principal, sans reflow forcé.

const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) {
      e.target.classList.add('visible');
      io.unobserve(e.target);          // une fois révélé, on arrête d'observer
    }
  }
}, { rootMargin: '0px 0px -40px 0px' });

document.querySelectorAll('.card').forEach(el => io.observe(el));

Solution 2 — si l'écoute du scroll est vraiment nécessaire : limiter la fréquence et écouter passivement.

let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => { onScroll(); ticking = false; });
}, { passive: true });

{ passive: true } indique au navigateur que vous n'appellerez pas preventDefault() : il peut alors scroller sans attendre votre code.

C5. Construction du DOM

// ❌ le navigateur re-analyse et reconstruit tout le sous-arbre à chaque tour
for (let i = 0; i < 200; i++) {
  host.innerHTML += '<span>' + label + '</span>';
}

// ✅ une seule insertion
const frag = document.createDocumentFragment();
for (let i = 0; i < 200; i++) {
  const s = document.createElement('span');
  s.className = 'tag';
  s.textContent = label;
  frag.appendChild(s);
}
host.appendChild(frag);

innerHTML += relit la totalité du contenu existant, le concatène, puis le re-parse. Le coût croît avec le carré du nombre d'éléments.

C6. Travail synchrone au chargement

Une boucle qui occupe le processeur pendant des centaines de millisecondes pendant le parsing bloque tout : rien ne s'affiche, rien ne répond. Sur le panneau Performance, c'est un bloc massif juste après la requête du script.

Cherchez : les boucles while sur performance.now(), les allocations de tableaux de plusieurs centaines de milliers d'entrées, les calculs exécutés au moment de la déclaration plutôt qu'à l'appel.

var S = { _cal: calibrate() };   // s'exécute immédiatement, au parsing
var S = { cal: calibrate };      // ne s'exécute que si on appelle S.cal()

C7. Dépendances tierces

Une bibliothèque « utilitaire » qui ne sert qu'à envelopper querySelector et classList coûte plus cher qu'elle ne rapporte. Avant de garder une dépendance, listez ce que vous en utilisez réellement.

Si le compte tient en une dizaine de lignes de JavaScript standard, réécrivez :

HelperÉquivalent natif
qs(s)document.querySelector(s)
qsa(s)document.querySelectorAll(s)
cls(e,n,true)e.classList.add(n)
ready(f)<script defer>, ou DOMContentLoaded
on(e,f)addEventListener(e,f)

Un fichier minifié n'est pas forcément intouchable. Dépliez-le (bouton {} du panneau Sources), lisez ce qu'il fait au démarrage, et jugez.

C8. MutationObserver et code réactif

Un MutationObserver posé sur document avec subtree:true et attributes:true se déclenche à chaque modification d'attribut n'importe où dans la page — y compris les style que votre propre code écrit en boucle. Si son callback parcourt tout le DOM, le coût se multiplie par le nombre de mutations.

Symptôme caractéristique : le scroll rame beaucoup plus que ce que le gestionnaire de scroll seul expliquerait. Deux morceaux de code anodins séparément s'amplifient mutuellement.

Correction : restreindre la portée de l'observation (cible précise, liste d'attributs surveillés), ou supprimer l'observateur s'il n'est pas nécessaire.

C9. Minuteurs et charges différées

Tout ce qui est déclenché par setTimeout ou setInterval après le chargement échappe aux audits courts. Un setInterval qui travaille toutes les quelques secondes ne dégrade pas le FCP ni le LCP : il dégrade l'expérience réelle, celle de quelqu'un qui reste sur la page.

Comment le trouver : enregistrement Performance long (20 s et plus), sans recharger. Des blocs qui reviennent à intervalle régulier dans la piste principale signent un minuteur. Leur espacement vous donne la période, ce qui permet de retrouver le setInterval correspondant dans les sources.

Vérifiez aussi le panneau Sources → Event Listener Breakpoints, et cherchez setInterval / setTimeout dans le code.

C10. Code conditionnel

Un comportement peut ne se manifester que sous certaines conditions : largeur de fenêtre, onglet visible, durée de présence, type de pointeur. C'est la raison pour laquelle un audit mobile et un audit desktop peuvent diverger radicalement.

Si vos mesures ne sont pas reproductibles, c'est une piste. Faites varier une seule condition à la fois : redimensionnez la fenêtre, attendez plus longtemps, changez d'onglet et revenez.

C11. Code obscurci

Du code peut être écrit pour être difficile à lire : identifiants d'une lettre, chaînes encodées en base64 (atob), accès aux propriétés par variable (window[x] au lieu de window.innerWidth).

Techniques de lecture :

  1. Pretty-print : bouton {} en bas du panneau Sources.
  2. Décoder les chaînes : collez-les dans la console, atob("aW5uZXJXaWR0aA==") répond immédiatement.
  3. Point d'arrêt : posez-en un dans la fonction et inspectez les variables au moment de l'exécution. Ce qui est illisible statiquement devient évident à l'exécution.
  4. Profiler plutôt que lire : vous n'avez pas besoin de comprendre chaque ligne pour constater qu'une fonction consomme 400 ms.

D. CSS et rendu

D1. Feuille de style majoritairement inutilisée

L'onglet Coverage vous donne le chiffre. Au-delà de 50 % d'inutilisé, un nettoyage s'impose.

Attention : Coverage mesure ce qui est utilisé sur la page chargée à cet instant. Une règle inutilisée sur l'accueil peut servir sur une page de détail. Vérifiez sur l'ensemble du site avant de supprimer, et appuyez-vous sur une recherche dans les fichiers HTML.

D2. Sélecteurs et propriétés coûteux

filter et backdrop-filter sur de grandes surfaces forcent le navigateur à composer des couches supplémentaires à chaque image. Les ombres multiples empilées (box-shadow avec trois couches sur tous les éléments) ont un coût de peinture réel.

D3. Animer les bonnes propriétés

Propriété animéeÉtapes déclenchées
top, left, width, height, marginLayout → Paint → Composite
color, background-color, box-shadowPaint → Composite
transform, opacityComposite seulement

N'animez que transform et opacity. Une animation de top ne tiendra jamais 60 images par seconde sur une machine modeste.

D4. Minification et concaténation

Une fois le nettoyage fait, minifier retire espaces et commentaires. Le gain est réel mais secondaire : minifier une feuille dont 95 % est inutile revient à compresser des déchets. Nettoyez d'abord, minifiez ensuite.


E. Ce qui relève du serveur

Sur GitHub Pages, ces leviers ne sont pas à votre main. Ils doivent tout de même figurer dans votre rapport, en expliquant ce que vous feriez ailleurs.