Back

Les Techniques de Performance Frontend Que Nous Avons Oubliées

Les Techniques de Performance Frontend Que Nous Avons Oubliées

La façon la plus rapide de ralentir un frontend moderne est de supposer que le framework gère la performance à votre place. La plupart des techniques bas niveau qui définissaient les sites web rapides il y a dix ans — dimensions d’images explicites, scripts tiers différés, indications font-display, preconnects manuels — restent pertinentes, mais le framework s’interpose désormais entre vous et la plateforme, et l’abstraction fuit d’une manière qu’un audit Lighthouse signalera volontiers. En 2019, ButterCMS écrivait que « les navigateurs supporteront bientôt le lazy loading nativement ». Cet avenir est arrivé, est devenu la norme, puis est tranquillement devenu quelque chose que nous avons cessé de vérifier. Cet article passe en revue les fondamentaux de performance frontend que nous avons délégués et les modes de défaillance en production qui apparaissent lorsque cette délégation se rompt.

Points Clés

  • Le loading="lazy" natif est supporté par tous les navigateurs majeurs depuis Safari 15.4 (mars 2022), donc tout wrapper Intersection Observer écrit avant cette date est du code mort qui alourdit inutilement le bundle.
  • Google Fonts peut servir des polices avec font-display: swap, mais les blocs @font-face personnalisés dans votre propre CSS n’héritent pas de ce comportement — chacun est une source potentielle de texte invisible sur les connexions lentes.
  • setTimeout(fn, 0) s’exécute dans la prochaine tâche et peut se retrouver au milieu d’une interaction utilisateur ; requestIdleCallback attend une véritable période d’inactivité, ce qui en fait la primitive adaptée aux travaux non urgents.
  • Utilisez defer pour les scripts qui interagissent avec le DOM et async uniquement pour les scripts véritablement indépendants, car les scripts async s’exécutent dans l’ordre d’arrivée réseau, et non dans l’ordre du document.
  • Depuis que l’INP a remplacé le FID comme Core Web Vital en mars 2024, les gestionnaires de scroll et de resize non limités qui bloquent le thread principal sont désormais un signal de classement, et pas seulement un problème de fluidité.

Les Attributs width/height Explicites sur les Images Préviennent Toujours le Layout Shift

Dans les applications React où les dimensions des images proviennent de réponses API au moment de l’exécution, le navigateur n’a aucun espace réservé à allouer, ce qui fait de chaque image chargée après le premier rendu une source potentielle de layout shift — indépendamment du fait que le composant image de votre framework gère correctement les ressources statiques. Le Cumulative Layout Shift est l’un des Core Web Vitals définis par la documentation CLS de web.dev, et le mode de défaillance visible est concret : la page saute lorsqu’une image se charge, et le tap de l’utilisateur atterrit sur le mauvais bouton.

L’abstraction qui fuit ici est le composant image du framework. Le <Image> de Next.js réserve de l’espace lorsque vous passez width et height, mais il ne fait rien pour les balises <img> brutes dans le contenu MDX, le HTML rendu par un CMS, ou tout balisage que le composant ne touche jamais. Les navigateurs modernes calculent un aspect-ratio implicite à partir des attributs width et height (MDN documente ce comportement), de sorte que les dimensions réservent de l’espace même lorsque le CSS remplace la taille rendue.

// Avant : les dimensions arrivent au moment de l'exécution, rien ne réserve d'espace
<img src={product.imageUrl} alt={product.name} />

// Après : les attributs définissent une boîte d'aspect-ratio avant le chargement de l'image
<img
  src={product.imageUrl}
  alt={product.name}
  width={product.width}
  height={product.height}
  style={{ width: '100%', height: 'auto' }}
/>

Lorsque l’API omet les dimensions, définissez plutôt un aspect-ratio sur le conteneur. Dans tous les cas, l’espace existe avant que les octets n’arrivent.

font-display: swap et Preconnect pour les Polices Personnalisées

Chaque bloc @font-face personnalisé sans font-display: swap est une source potentielle de texte invisible — FOIT — sur les connexions lentes, où un paragraphe reste vide pendant toute la durée du chargement de la police. Le descripteur font-display contrôle ce comportement directement : swap affiche immédiatement le texte de substitution et bascule vers la police personnalisée une fois chargée, produisant un flash de texte non stylisé (FOUT) à la place, comme décrit dans la référence MDN de font-display.

La fuite vient de la délégation. Google Fonts peut injecter font-display: swap dans le CSS qu’il sert lorsque l’URL de la feuille de style inclut le paramètre display approprié, si bien que les équipes qui utilisent la feuille de style hébergée n’y pensent jamais — puis écrivent leurs propres blocs @font-face pour les polices de marque qui n’héritent pas de ce comportement. Une police auto-hébergée sans ce descripteur envoie du FOIT à chaque visiteur avec un cache vide.

L’auto-hébergement supprime également le preconnect qu’encourageait implicitement la feuille de style Google. Les recommandations de web.dev sur les connexions réseau anticipées préconisent de se préconnecter à l’origine de la police afin que les handshakes DNS, TCP et TLS soient terminés avant que l’URL de la police ne soit découverte dans le CSS.

@font-face {
  font-family: "BrandSans";
  src: url("/fonts/brand-sans.woff2") format("woff2");
  font-display: swap; /* le texte de substitution s'affiche immédiatement, pas de FOIT */
}
<link rel="preconnect" href="https://fonts.cdn.example.com" crossorigin />

Auditez chaque bloc @font-face que vous avez écrit manuellement. L’habitude du CSS hébergé masque ceux qui nécessitent une correction.

preconnect et dns-prefetch pour les Origines Tierces

Les bundlers et les frameworks gèrent le preconnect pour leurs propres origines CDN, mais les endpoints d’analytics tiers, les CDN d’images et les services de tests A/B sont invisibles pour l’étape de build — leurs résolutions DNS se produisent au moment de la requête, sauf si vous ajoutez manuellement <link rel="preconnect">. ButterCMS a décrit le mécanisme avec précision en 2019 : preconnect indique au navigateur de compléter la résolution DNS, la connexion initiale et la négociation TLS « le plus tôt possible, plutôt que plus tard lorsque la balise script est découverte ».

Le coût du handshake DNS et TLS n’a pas disparu ; le framework a simplement cessé de vous le rappeler. Un endpoint Segment, une origine Cloudinary ou un gestionnaire de tags tiers nécessitent chacun une nouvelle configuration de connexion qui bloque la ressource derrière eux. Utilisez preconnect pour les origines que vous savez atteindre tôt, et dns-prefetch comme indication plus légère pour les origines que vous pourriez atteindre, car preconnect ouvre une connexion complète que vous payez qu’elle soit utilisée ou non. web.dev couvre le compromis entre les deux indications.

<!-- Origine tierce critique : ouvrir la connexion complète maintenant -->
<link rel="preconnect" href="https://cdn.imagecdn.example" crossorigin />

<!-- Origine probable mais incertaine : résoudre uniquement le DNS -->
<link rel="dns-prefetch" href="https://analytics.example.com" />

Placez ces éléments en haut du <head>, avant les scripts et feuilles de style qui déclenchent les requêtes.

Le loading="lazy" Natif a Remplacé Votre Wrapper Intersection Observer

Le loading="lazy" natif est supporté par tous les navigateurs majeurs depuis Safari 15.4 en mars 2022 — tout wrapper Intersection Observer écrit avant cette date est désormais du code mort qui alourdit le bundle et la surface de maintenance. Chrome l’a introduit dans la version 77 (août 2019) et Firefox dans la version 75 (avril 2020), selon le tableau de compatibilité navigateurs dans la référence MDN de l’élément img.

La fuite ici est historique, et non spécifique à un framework. Les bases de code ont accumulé des hooks useLazyImage et des composants <LazyImage> dans les années précédant la standardisation de l’attribut, et ces composants continuent de se déployer — exécutant un observer par image, maintenant des refs, et re-rendant à l’intersection — pour faire ce que le navigateur fait désormais nativement et hors du thread principal. Le même attribut fonctionne sur les iframes, ce qui est important pour les cartes intégrées et les lecteurs vidéo en dessous de la ligne de flottaison.

// Avant : un observer artisanal que la plateforme a rendu obsolète
function LazyImage({ src, alt }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) setVisible(true);
    });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  return <img ref={ref} src={visible ? src : undefined} alt={alt} />;
}

// Après : le navigateur s'en charge, hors du thread principal
<img src={src} alt={alt} loading="lazy" width={800} height={600} />;

Conservez les dimensions explicites — les images chargées en lazy provoquent des layout shifts tout aussi facilement que les images chargées normalement.

defer vs async sur les Scripts Tiers

La règle de décision pratique : utilisez defer pour tout script qui lit ou écrit dans le DOM, et async uniquement pour les scripts véritablement autonomes — car les scripts async s’exécutent dans l’ordre d’arrivée réseau, et non dans l’ordre du document, et deux scripts async ayant une dépendance entre eux entreront en compétition. La définition de l’élément script dans le HTML Living Standard spécifie que les scripts defer s’exécutent après la fin du parsing, dans l’ordre du document, tandis que les scripts async s’exécutent dès qu’ils ont fini de se charger.

La fuite est sociale, pas technique : quelqu’un colle un snippet d’analytics vendeur dans <head> exactement comme les instructions copier-coller du vendeur le montrent, sans attribut, et un <script> ordinaire bloque le parsing jusqu’à ce qu’il soit téléchargé et exécuté. Le mode de défaillance visible est un délai d’interaction. Lorsque des scripts tiers bloquent l’interaction, les replays montrent des taps répétés sur le même contrôle — un pattern classique de rage-click.

AttributMoment d’exécutionGarantie d’ordreUtiliser pour
aucunBloque le parser immédiatementOrdre du documentPresque jamais
asyncDès que chargéOrdre d’arrivée réseauAnalytics indépendants
deferAprès la fin du parsingOrdre du documentTout ce qui touche le DOM
<!-- Avant : bloque le parser, retarde le premier rendu et l'interaction -->
<script src="https://vendor.example/analytics.js"></script>

<!-- Après : script indépendant, ne bloque jamais le parser -->
<script src="https://vendor.example/analytics.js" async></script>

requestIdleCallback Plutôt que setTimeout(fn, 0)

setTimeout(fn, 0) planifie du travail dans le prochain créneau de la file de tâches, qui peut tomber en plein milieu d’une interaction utilisateur ; requestIdleCallback attend une véritable période d’inactivité, ce qui en fait la primitive adaptée à l’initialisation d’analytics, l’hydratation de prefetch et le traitement par lots de télémétrie. La distinction est documentée dans la référence MDN de requestIdleCallback : le callback se déclenche pendant les périodes d’inactivité du navigateur et reçoit une deadline que vous pouvez vérifier avant d’effectuer davantage de travail.

C’est la primitive que la plupart des équipes n’ont jamais adoptée — setTimeout(fn, 0) est devenu le réflexe « faire ça plus tard », et il ne cède pas réellement la main à l’utilisateur. Depuis que l’INP a remplacé le FID comme Core Web Vital en mars 2024 (selon l’annonce INP de web.dev), le travail sur le thread principal qui se produit pendant une interaction n’est plus seulement un problème de fluidité — c’est un signal de classement. requestIdleCallback est supporté dans Chrome et Firefox mais pas dans Safari, donc détectez la fonctionnalité et prévoyez un fallback.

function whenIdle(fn) {
  if ("requestIdleCallback" in window) {
    requestIdleCallback(fn, { timeout: 2000 });
  } else {
    setTimeout(fn, 0); // Fallback Safari
  }
}

// Différer le travail non urgent hors du chemin d'interaction
whenIdle(() => initAnalytics());

L’option timeout garantit que le travail s’exécute éventuellement même si le navigateur ne passe jamais en état d’inactivité.

Debounce et Throttle sur les Événements Scroll, Resize et Input

Les gestionnaires de scroll, resize et input non limités qui bloquent le thread principal sont désormais un signal de classement, et pas seulement un problème de fluidité — chaque frame qu’ils retardent est une violation INP potentielle. Le pattern s’est dégradé parce que useEffect rend trivial l’attachement d’un listener brut : trois lignes, aucune limitation de débit, et un gestionnaire qui se déclenche à chaque frame de scroll.

Le debounce exécute une fonction après que l’activité s’est arrêtée — adapté aux champs de recherche et aux travaux de fin de redimensionnement. Le throttle plafonne la fréquence — adapté au suivi de position de scroll qui doit se mettre à jour pendant le geste. La référence MDN de l’événement scroll note que les événements scroll peuvent se déclencher à haute fréquence et recommande de limiter les gestionnaires coûteux.

useEffect(() => {
  let ticking = false;
  function onScroll() {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      updateScrollPosition(window.scrollY);
      ticking = false;
    });
  }
  window.addEventListener("scroll", onScroll, { passive: true });
  return () => window.removeEventListener("scroll", onScroll);
}, []);

La porte requestAnimationFrame limite à une mise à jour par frame, et { passive: true } indique au navigateur que le gestionnaire n’appellera pas preventDefault, lui permettant de scroller sans attendre votre JavaScript.

Le Pattern Cumulatif

Chaque technique de cet article est une connaissance de la plateforme que nous avons déléguée à un comportement par défaut du framework et cessé de vérifier. Aucune d’entre elles n’est nouvelle — c’est précisément le point. Individuellement, un font-display manquant ou une balise non différée coûte quelques millisecondes ; ensemble, ils représentent l’écart entre une application qui se sent rapide et une qui se sent lourde malgré des outils modernes. La prochaine action concrète : ouvrez DevTools, auditez vos blocs @font-face écrits manuellement, vos balises <script> tierces et vos listeners useEffect par rapport aux règles ci-dessus, et supprimez le wrapper Intersection Observer que le navigateur a rendu obsolète.

FAQ

Utilisez debounce lorsque vous ne vous intéressez qu'à l'état final après que l'activité s'est arrêtée, par exemple pour déclencher une requête de recherche après que l'utilisateur a cessé de taper, ou pour recalculer la mise en page après la fin d'un redimensionnement. Utilisez throttle lorsque vous avez besoin de mises à jour pendant un geste continu à une fréquence plafonnée, comme le suivi de la position de scroll. Le debounce attend une pause ; le throttle limite la fréquence pendant que l'événement continue de se déclencher.

Oui. L'attribut loading s'applique aux éléments img et iframe, de sorte que les cartes intégrées, les lecteurs vidéo et les widgets tiers en dessous de la ligne de flottaison peuvent différer leur chargement nativement sans wrapper Intersection Observer. Le support navigateur suit de près le déploiement pour les images, atteignant la norme dans Chrome, Firefox et Safari à la même époque. Conservez des valeurs width et height explicites pour éviter les layout shifts, car les éléments chargés en lazy en provoquent tout autant que les éléments chargés normalement.

Ils entrent en compétition et peuvent s'exécuter dans le mauvais ordre. Les scripts async s'exécutent dès que chacun a fini de se télécharger, dans l'ordre d'arrivée réseau plutôt que dans l'ordre du document, de sorte qu'un script qui dépend d'un autre script async peut s'exécuter en premier et échouer. La solution est d'utiliser defer pour les deux scripts, ce qui garantit l'exécution après la fin du parsing et dans l'ordre du document, ou de charger la dépendance avant le script dépendant dans un bundle unique.

setTimeout avec un délai de zéro planifie du travail dans le prochain créneau de la file de tâches, que le navigateur peut exécuter immédiatement, y compris au milieu d'une interaction utilisateur, donc il ne cède pas réellement la main à l'utilisateur. requestIdleCallback attend une véritable période d'inactivité et transmet une deadline que vous pouvez vérifier avant de continuer. Depuis que l'INP est devenu un Core Web Vital en mars 2024, cette distinction est importante car le travail qui se produit pendant une interaction est désormais un signal de classement.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay