Web PerformanceJune 14, 2026·12 min de lecture·Miracle KaluMiracle Kalu

Le script tiers le plus rapide est celui qui ne s'exécute jamais

Aerial view of congested multi-lane highway traffic at dusk.

La version la plus rapide d'un script tiers est celle qui ne s'exécute jamais. Je l'ai réappris sur un site d'actualités à fort trafic où la réponse du serveur était déjà bonne et où la page semblait quand même cassée. Le HTML arrivait vite, le titre de l'article était là, dans le balisage, et pourtant la page restait là, saccadée et insensible, pendant que le thread principal se noyait dans le JavaScript des autres.

Voici donc l'histoire de ce site. Les chiffres qui ont révélé le problème, les schémas qui l'ont fait passer de gênant à rapide, et les quelques endroits où aller vite est entré en conflit avec ce que l'entreprise voulait vraiment. Aucune de ces techniques n'est astucieuse. Il s'agit surtout de refuser de charger des choses tant qu'on n'a pas une raison concrète de le faire.

Un serveur rapide n'est pas une page rapide

Le site tournait sur une stack moderne avec rendu côté serveur et un cache correct devant. Les bons jours, le document arrivait en quelques centaines de millisecondes. Selon la seule métrique qui importe à la plupart des tableaux de bord backend, tout allait bien.

Tout n'allait pas bien. Sur un téléphone Android de milieu de gamme en 4G bridée, la page donnait l'impression d'avancer dans du sable mouillé. Les appuis ne faisaient rien pendant un instant. Le défilement saccadait. Le titre de l'article, qui se trouvait dans le HTML initial, mettait près de cinq secondes à réellement s'afficher.

Cet écart entre "le serveur est rapide" et "la page semble rapide" est l'endroit où vit le JavaScript tiers. Le Time to First Byte mesure la vitesse à laquelle le serveur commence à parler. Il ne dit rien de ce qui se passe une fois les octets arrivés, quand le navigateur doit analyser, compiler et exécuter chaque script que la page tire à elle. La métrique qui capture cette douleur est le Total Blocking Time, et en laboratoire c'est un bon indicateur de l'impression de lenteur d'une page avant qu'elle ne se stabilise. Sur le terrain, le même problème se manifeste par un mauvais Interaction to Next Paint.

La forme du problème

Le gabarit d'article intégrait la distribution habituelle : tweets Twitter/X, posts Instagram, clips TikTok, un gestionnaire de balises et un fournisseur d'analytics. Chacun embarque son propre SDK, et chaque SDK veut s'exécuter à l'instant où la page charge. Le navigateur, obéissant, fait exactement cela.

Quand j'ai ouvert le panneau de performance et enregistré un article typique sur un profil bridé, le tableau était sombre, et presque rien n'était mon code :

  • Le seul widgets.js de Twitter coûtait environ 816 ms d'exécution sur le thread principal.
  • Les SDK sociaux réunis ajoutaient bien plus d'une seconde et demie de travail bloquant.
  • Le gestionnaire de balises apportait à lui seul une tâche de 241 ms.
  • Le fournisseur d'analytics et quelques scripts plus petits empilaient encore quelques centaines de millisecondes.

Le plus cruel, c'est que la plupart de ces intégrations étaient sous la ligne de flottaison. Une lectrice sur son téléphone payait 800 ms pour afficher un tweet jusqu'auquel elle ne défilerait peut-être jamais. L'élément Largest Contentful Paint était un simple <h1> déjà présent dans le HTML serveur, mais il ne pouvait pas s'afficher tant que le thread principal était occupé à compiler et exécuter du code d'intégration qui n'avait rien à voir avec le titre.

Voici à peu près à quoi ressemblait le chargement avant tout ce travail :

Voici la partie inconfortable : votre budget de performance ne vous appartient pas vraiment. Vous en louez l'essentiel à des fournisseurs, et ils le dépensent sans demander. Toute solution sérieuse doit commencer par récupérer ce budget.

Trouvez les vrais coupables avant de toucher à quoi que ce soit

Il est tentant de supprimer des scripts à l'instinct. Résistez. La première tâche est l'attribution, car le script que vous croyez en cause n'est souvent pas celui qui vous coûte le plus.

Deux outils ont fait l'essentiel du travail ici. Le graphique en flammes du thread principal, dans le panneau de performance, vous montre exactement quel script squatte le CPU et pour combien de temps. Groupez l'enregistrement par tâches longues, celles de plus de 50 ms, et les fautifs se classent d'eux-mêmes en haut. Le deuxième outil est la version « attribution » de la bibliothèque web-vitals, qui sur le terrain vous dit non seulement que le LCP était lent mais pourquoi, en le découpant en Time to First Byte, délai de chargement de la ressource, temps de chargement de la ressource et délai de rendu de l'élément. Sur ce site, la valeur du délai de rendu était énorme tandis que toutes les autres parties étaient proches de zéro, ce qui pointait directement vers une contention du thread principal plutôt qu'un réseau lent ou une image lourde.

Vous voulez des chiffres comme ceux-là notés avant et après chaque changement. Sinon vous optimisez au feeling, et le feeling ne survit pas à un responsable qui demande si le travail en valait la peine.

Arrêtez de charger ce que personne n'a demandé

Le premier vrai geste est aussi le plus efficace. Ne chargez pas un SDK d'intégration tant que l'intégration n'est pas sur le point d'être vue. Le navigateur fournit déjà l'outil pour cela : `IntersectionObserver`.

Au lieu de laisser les balises <script> de la plateforme s'exécuter au moment de l'analyse, retirez-les côté serveur et injectez un petit observateur qui ne charge chaque SDK que lorsque son intégration approche du viewport. Observez aussi chaque intégration individuellement, pour qu'un tweet proche du haut n'entraîne pas le SDK TikTok tout en bas. Un unique chargeur partagé qui se déclenche dès qu'une intégration apparaît gâche l'essentiel du bénéfice.

type EmbedKind = "twitter" | "instagram" | "tiktok";

const SDK_URL: Record<EmbedKind, string> = {
  twitter: "https://platform.twitter.com/widgets.js",
  instagram: "https://www.instagram.com/embed.js",
  tiktok: "https://www.tiktok.com/embed.js",
};

const loaded = new Set<EmbedKind>();

function loadSdk(kind: EmbedKind): void {
  if (loaded.has(kind)) return; // ne jamais injecter deux fois le meme SDK
  loaded.add(kind);
  const s = document.createElement("script");
  s.src = SDK_URL[kind];
  s.async = true;
  s.setAttribute("fetchpriority", "low"); // il peut attendre derriere le vrai contenu
  document.body.appendChild(s);
}

const io = new IntersectionObserver(
  (entries, observer) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const kind = entry.target.getAttribute("data-embed") as EmbedKind;
      loadSdk(kind);
      observer.unobserve(entry.target); // charger une fois, puis arreter d observer
    }
  },
  { rootMargin: "300px" }, // demarrer un peu avant que l integration soit a l ecran
);

document
  .querySelectorAll<HTMLElement>("[data-embed]")
  .forEach((el) => io.observe(el));

Deux réglages comptent ici. Le rootMargin de 300px lance le chargement juste avant que l'intégration n'entre dans le champ, de sorte que le contenu est généralement prêt quand la lectrice y arrive, au lieu d'apparaître en retard. Et fetchpriority="low" indique au navigateur que ces requêtes peuvent attendre derrière les polices, le CSS et l'image LCP. L'API Priority Hints est à peu près le gain le moins cher que vous trouverez. Il y a aussi un repli à garder : si IntersectionObserver n'est pas disponible, chargez les SDK en quinconce avec de petits délais entre eux plutôt que tous d'un coup, pour que même le chemin des vieux navigateurs ne bloque pas le thread principal.

Ce seul changement a déplacé environ une seconde et demie de travail bloquant hors du chemin critique sur les articles chargés d'intégrations. Mais il a encore un défaut, et ce défaut est une hypothèse.

Quand paresseux est encore trop empressé : la façade

Le chargement paresseux au défilement suppose que la lectrice veut l'intégration. Beaucoup n'en veulent pas. Elles sont venues pour l'article, elles défilent au-delà du tweet, et elles n'ont jamais eu besoin de 200 Ko de code de widget pour ça. Pour les pires fautifs, je suis allé plus loin et j'ai remplacé l'intégration vivante par une façade : du HTML statique bon marché qui ressemble à l'intégration, le vrai SDK ne se chargeant que sur un clic ou une véritable interaction.

C'est le schéma import-on-interaction, appliqué au code des autres plutôt qu'au vôtre. Voici le cycle de vie :

Pour rendre cela robuste, il a fallu trois leçons, et j'en ai appris deux à la dure.

La première leçon portait sur la provenance réelle des intégrations. Le CMS renvoyait certaines intégrations en balisage <blockquote> et d'autres en éléments <iframe> complets. Ma première façade ne gérait que les blockquotes, alors les intégrations en iframe allaient joyeusement chercher leur charge utile pendant l'analyse du HTML, et la façade ne servait à rien. La solution a été de neutraliser les iframes côté serveur avant qu'ils n'atteignent le navigateur, en déplaçant src vers data-src et en ne le restaurant qu'à l'activation.

function activateEmbed(container: HTMLElement): void {
  const iframe = container.querySelector<HTMLIFrameElement>("iframe[data-src]");
  if (!iframe) return;

  const run = () => {
    iframe.src = iframe.dataset.src!; // restaurer la vraie source
    iframe.removeAttribute("data-src");
  };

  // Planifier pendant un temps mort pour ne jamais concurrencer un appui ou un defilement.
  if ("requestIdleCallback" in window) {
    requestIdleCallback(run, { timeout: 2000 });
  } else {
    setTimeout(run, 1);
  }
}

La deuxième leçon m'a coûté un après-midi. Ma première version générait le balisage de la façade côté serveur et tentait de câbler le gestionnaire de clic avec un <script> en ligne injecté dans le HTML de l'article. Il ne faisait silencieusement rien. React n'exécute pas les balises <script> insérées via dangerouslySetInnerHTML, donc le gestionnaire ne s'attachait jamais et les intégrations ne chargeaient tout simplement jamais. Les façades avaient l'air correctes et étaient complètement mortes. La solution a été d'arrêter de faire passer du comportement par des chaînes HTML et de déplacer le tout dans un vrai composant client qui s'exécute au montage, trouve les façades et attache ses propres écouteurs. Si vous ne retenez qu'une chose pratique de ce texte, que ce soit celle-ci : le comportement appartient aux composants, pas au HTML que vous injectez sous forme de chaîne.

La troisième leçon portait sur la planification. Quand le vrai script finit par charger, enveloppez l'injection dans `requestIdleCallback` pour qu'elle se glisse dans un moment calme au lieu de se battre contre une interaction que l'utilisatrice est en train de faire. Le clic pour charger sur la seule pire intégration a retiré 816 ms de plus du chargement initial, parce que le SDK de Twitter ne s'exécutait tout simplement jamais pour la plupart des lecteurs.

Là où la vitesse s'est heurtée au métier

Le travail de performance ne se fait pas dans le vide, et c'est la partie que la plupart des comptes rendus sautent. Deux de mes « victoires » ont causé de vrais problèmes.

La première était le lecteur vidéo. J'ai remplacé le lourd lecteur intégré par un poster statique et un bouton de lecture, ce qui est le bon choix pour le temps de chargement. Mais la façade masquait le titre de la vidéo, et il s'est avéré que le titre faisait un vrai travail : les lecteurs décidaient de lancer la lecture en fonction de lui. Les lectures ont chuté. Le compromis a été de garder la façade légère mais de la faire passer automatiquement au lecteur complet une seconde après la première interaction de la lectrice avec la page, planifiée pendant un temps mort. Aucun coût pendant la fenêtre qui décide de vos Core Web Vitals, pleine fonctionnalité juste après.

Le second problème était que les intégrations restaient mortes jusqu'au clic. La rédaction n'aimait pas que les lecteurs doivent cliquer sur chaque tweet pour le voir. Alors les façades ont reçu le même comportement : écouter le premier scroll, click, keydown ou touchstart, et une seconde plus tard activer discrètement les intégrations restantes pendant un temps mort. Les clics directs fonctionnent toujours immédiatement. Le résultat garde le thread principal libre pendant toute la fenêtre de mesure, puis restaure l'expérience « tout se charge tout seul » dès que la lectrice montre le moindre signe de vie.

La leçon que je réapprends sans cesse : une solution de performance qui casse le produit n'est pas une solution, c'est une régression avec de bons chiffres. La mise à niveau déclenchée par l'interaction est le schéma qui réconcilie les deux.

Les scripts que vous ne pouvez pas supprimer

Certaines choses doivent charger : analytics, gestion du consentement, un gestionnaire de balises dont dépend l'équipe marketing. Vous ne pouvez pas les supprimer, mais vous pouvez refuser de les laisser s'exécuter pendant la fenêtre qui compte.

Pour celles-là, j'ai empilé des portes. Attendre l'événement load, puis un court minuteur, puis requestIdleCallback avec un timeout pour qu'il se déclenche encore sur une page chargée. Le gestionnaire de balises est passé d'une tâche de 241 ms qui se battait contre le premier rendu à un travail discret qui s'exécute une fois la page interactive et au repos.

function loadWhenIdle(src: string, delayMs = 3000): void {
  const inject = () => {
    const s = document.createElement("script");
    s.src = src;
    s.async = true;
    s.setAttribute("fetchpriority", "low");
    document.body.appendChild(s);
  };

  // evenement load -> court delai -> repos. Chaque porte repousse le travail plus loin.
  window.addEventListener("load", () => {
    setTimeout(() => {
      if ("requestIdleCallback" in window) {
        requestIdleCallback(inject, { timeout: 8000 });
      } else {
        inject();
      }
    }, delayMs);
  });
}

L'autre astuce pour les scripts dont vous êtes prisonnier, c'est le cache. Un fournisseur qui sert son fichier avec un en-tête de cache d'un jour est un fournisseur que vous retéléchargez constamment, et vous n'avez aucun contrôle sur cet en-tête. Faites passer l'asset par votre propre domaine, fixez une durée de cache raisonnable, et un coût réseau répété devient quasiment unique. Cela vous donne aussi une seule gorge à serrer quand le tiers tombe en panne. Dans un framework comme Next.js, vous pouvez faire le proxy avec une réécriture et une règle d'en-tête de cache :

// next.config.ts
const nextConfig = {
  async rewrites() {
    return [
      { source: "/_proxy/analytics.js", destination: "https://vendor.example/sdk.js" },
    ];
  },
  async headers() {
    return [
      {
        source: "/_proxy/:path*",
        headers: [
          {
            key: "Cache-Control",
            value: "public, max-age=604800, stale-while-revalidate=31536000",
          },
        ],
      },
    ];
  },
};

export default nextConfig;

Une façon de penser chaque script tiers

Après assez de ces cas, les astuces individuelles se condensent en une seule décision que vous pouvez dérouler sur n'importe quel script avant de le laisser approcher d'une page.

La plupart des scripts ne dépassent jamais la deuxième question, et c'est tout l'intérêt. La réponse par défaut est « ne pas charger », et chaque « oui » doit gagner sa place.

Les chiffres

Sur l'ensemble du travail, les changements d'intégration et de scripts ont retiré bien plus de deux secondes de travail bloquant du chargement initial d'un article lourd. Le Total Blocking Time sur la page de test est tombé dans le vert, confortablement sous la barre des 200 ms, et le titre a commencé à s'afficher à peu près dans le temps que le serveur avait toujours promis. Le score de performance Lighthouse est passé des hauts soixante-dix aux bas quatre-vingt-dix, mais le score n'a jamais été le but. Le but était que la page cesse de se battre contre la lectrice.

Ce qui a fait tenir tout cela, c'est de mesurer chaque changement sur un profil mobile bridé, pas sur un portable de développeur. Le temps de blocage qui ruine l'expérience est presque invisible sur du matériel rapide, et c'est exactement pour ça qu'il survit si longtemps en production.

À retenir

  • Traitez chaque script tiers comme coupable jusqu'à preuve de sa nécessité. Le réglage par défaut devrait être « ne pas charger », pas « charger et espérer ».
  • Attribuez avant d'agir. Utilisez le graphique en flammes du thread principal et l'attribution web-vitals pour trouver le script qui vous coûte vraiment, pas celui que vous supposez.
  • Différez par visibilité avec IntersectionObserver, et par intention avec des façades. La plupart des lecteurs ne déclenchent jamais le chemin coûteux, et c'est exactement ce que vous voulez.
  • Utilisez fetchpriority="low" et requestIdleCallback pour garder le travail différé hors de la fenêtre critique. Des changements d'une ligne, un effet démesuré.
  • Neutralisez côté serveur, pas côté client. Dès qu'un <iframe src> atteint le navigateur, vous avez déjà perdu la requête. Et rappelez-vous que React n'exécute pas un <script> que vous injectez sous forme de chaîne.
  • Surveillez le produit, pas seulement les métriques. Une solution qui masque un titre de vidéo ou casse une intégration est une régression avec un joli score. Le chargement déclenché par l'interaction est la façon d'obtenir les deux.
  • Mesurez sur un profil de téléphone bridé. Le temps de blocage qui ruine l'expérience est invisible sur du matériel rapide.

Le titre était dans le HTML depuis le début. Le vrai travail consistait à apprendre au navigateur à l'afficher avant de faire les courses de tout le monde.

Partager :

XLinkedIn
Miracle Kalu

Écrit par

Miracle Kalu

Senior Full Stack Engineer

Vous avez aimé cet article ?

Je suis disponible pour des rôles d'ingénierie senior et du conseil technique. Parlons-en.

Prendre contact →

Publié le 14 juin 2026 · 12 min de lecture