Pourquoi mesurer côté navigateur ?

Optimiser une interface web sans mesure revient souvent à travailler à l’intuition. On compresse quelques images, on supprime deux dépendances, on décale un script, puis on espère que l’expérience utilisateur s’améliore. C’est parfois suffisant, mais pas lorsque l’on veut piloter sérieusement la performance frontend.

Les outils comme Lighthouse, WebPageTest ou les DevTools sont indispensables, mais ils mesurent surtout des scénarios contrôlés. Or vos utilisateurs naviguent avec des téléphones différents, des réseaux instables, des extensions, des onglets déjà chargés et parfois des appareils très modestes. C’est précisément là que PerformanceObserver devient intéressant : il permet d’observer des événements de performance directement dans le navigateur, pendant l’usage réel de la page.

Cette approche complète très bien les optimisations abordées dans l’optimisation des images web, le cache frontend, les Web Workers ou encore Intersection Observer. Ces techniques améliorent la performance ; PerformanceObserver vous aide à vérifier leur impact.

Comprendre PerformanceObserver

PerformanceObserver est une API JavaScript native qui permet d’écouter certains types d’entrées de performance produites par le navigateur : navigation, ressources, long tasks, layout shifts, largest contentful paint, first input, etc.

L’idée générale est simple :

  • le navigateur mesure déjà beaucoup de choses ;
  • vous créez un observateur ;
  • vous indiquez les types d’entrées à surveiller ;
  • vous traitez les mesures reçues.

Voici un exemple minimal :

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.entryType, entry.name, entry.startTime, entry.duration);
  }
});

observer.observe({
  type: 'resource',
  buffered: true
});

L’option buffered: true est importante : elle demande au navigateur de fournir aussi les entrées déjà collectées avant la création de l’observateur, quand le type d’entrée le permet.

Observer le chargement des ressources

Le type resource permet d’analyser les fichiers chargés par la page : scripts, feuilles CSS, images, polices, requêtes API, etc. C’est utile pour repérer les ressources lentes, trop lourdes ou appelées trop tôt.

const slowResourcesObserver = new PerformanceObserver((list) => {
  const slowResources = list.getEntries()
    .filter((entry) => entry.duration > 500)
    .map((entry) => ({
      name: entry.name,
      type: entry.initiatorType,
      duration: Math.round(entry.duration),
      startTime: Math.round(entry.startTime)
    }));

  if (slowResources.length > 0) {
    console.table(slowResources);
  }
});

slowResourcesObserver.observe({
  type: 'resource',
  buffered: true
});

Cette mesure peut rapidement révéler des problèmes concrets : une police bloquante, une image héroïque trop lourde, un script tiers lent, une requête API mal mise en cache. Pour les images, elle doit être analysée avec les attributs srcset, sizes, loading et fetchpriority, comme expliqué dans Optimiser les images web sans sacrifier la qualité.

Attention cependant : toutes les informations ne sont pas toujours disponibles pour les ressources cross-origin. Pour obtenir des timings détaillés sur un domaine externe, le serveur distant doit généralement envoyer l’en-tête Timing-Allow-Origin.

Détecter les longues tâches JavaScript

Une page peut se charger vite mais rester désagréable à utiliser si le thread principal est régulièrement bloqué. C’est typiquement le cas lorsqu’un composant effectue un gros calcul, parse beaucoup de JSON ou déclenche trop de rendu.

Le type longtask permet de détecter les tâches qui bloquent le thread principal pendant plus de 50 ms.

const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn('Long task détectée', {
      duration: Math.round(entry.duration),
      startTime: Math.round(entry.startTime)
    });
  }
});

longTaskObserver.observe({
  type: 'longtask',
  buffered: true
});

Une longue tâche isolée n’est pas nécessairement dramatique. En revanche, si vous observez des blocages fréquents pendant une interaction, il faut enquêter. Plusieurs solutions sont possibles : découper le travail, différer certaines opérations, réduire la quantité de JavaScript envoyée au navigateur, ou déplacer les calculs dans un worker comme présenté dans Web Workers en JavaScript.

Dans une application React ou Next.js, cela peut aussi signaler une frontière serveur/client mal placée. Les React Server Components peuvent aider à réduire le volume de JavaScript exécuté côté navigateur lorsque l’architecture s’y prête.

Mesurer le Largest Contentful Paint

Le Largest Contentful Paint, souvent abrégé LCP, mesure le moment où le plus grand élément visible principal est rendu. Il s’agit généralement d’une image, d’un bloc de texte ou d’un élément héroïque.

const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];

  console.log('LCP', {
    value: Math.round(lastEntry.startTime),
    element: lastEntry.element,
    url: lastEntry.url
  });
});

lcpObserver.observe({
  type: 'largest-contentful-paint',
  buffered: true
});

Le LCP est très sensible à la structure initiale de la page. Une grande image non optimisée, une police bloquante, un rendu serveur trop lent ou une dépendance JavaScript indispensable au premier affichage peuvent le dégrader.

Pour améliorer cette métrique, commencez par identifier l’élément concerné. S’il s’agit d’une image, vérifiez son format, ses dimensions, sa priorité de chargement et sa présence dans le HTML initial. S’il s’agit d’un bloc généré par JavaScript, demandez-vous s’il pourrait être rendu plus tôt, côté serveur, ou avec moins de dépendances.

Surveiller les décalages de mise en page

Les layout shifts correspondent aux déplacements inattendus d’éléments pendant le chargement ou l’usage d’une page. Ils donnent cette impression désagréable de bouton qui bouge au moment du clic ou de contenu qui saute après l’arrivée d’une image.

PerformanceObserver permet d’écouter le type layout-shift :

let cumulativeLayoutShift = 0;

const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      cumulativeLayoutShift += entry.value;
    }
  }

  console.log('CLS courant', cumulativeLayoutShift.toFixed(4));
});

clsObserver.observe({
  type: 'layout-shift',
  buffered: true
});

La condition !entry.hadRecentInput évite de compter les déplacements causés par une interaction utilisateur récente. Un menu qui s’ouvre après un clic est attendu ; une bannière qui pousse tout le contenu sans prévenir ne l’est pas.

Les causes fréquentes sont connues : images sans dimensions réservées, publicités injectées tardivement, polices qui changent la taille du texte, composants asynchrones qui apparaissent sans espace prévu. Les container queries, abordées dans Container queries CSS, peuvent aider à concevoir des composants adaptatifs, mais elles ne remplacent pas la réservation correcte de l’espace.

Envoyer les mesures vers votre backend

Afficher les métriques dans la console est utile pendant le développement, mais l’intérêt réel apparaît lorsque vous collectez ces données en production. L’approche minimale consiste à envoyer des événements agrégés vers une route d’observabilité.

type PerformanceMetric = {
  name: 'LCP' | 'CLS' | 'LONG_TASK';
  value: number;
  path: string;
  userAgent: string;
};

function sendMetric(metric: PerformanceMetric) {
  const body = JSON.stringify(metric);

  navigator.sendBeacon('/api/performance', body);
}

navigator.sendBeacon est adapté à ce cas, car il permet d’envoyer de petites quantités de données sans bloquer la navigation. Pour des besoins plus avancés, vous pouvez aussi utiliser fetch, idéalement avec une stratégie d’annulation propre grâce à AbortController.

Côté serveur, une route Node.js très simple pourrait recevoir ces métriques :

import http from 'node:http';

const server = http.createServer(async (request, response) => {
  if (request.method === 'POST' && request.url === '/api/performance') {
    const chunks: Buffer[] = [];

    for await (const chunk of request) {
      chunks.push(Buffer.from(chunk));
    }

    const payload = Buffer.concat(chunks).toString('utf8');
    console.log('Métrique reçue', JSON.parse(payload));

    response.writeHead(204);
    response.end();

    return;
  }

  response.writeHead(404);
  response.end();
});

server.listen(3000);

En production, vous ne vous contenterez pas d’un console.log. Vous pourrez envoyer ces données dans une base analytique, un outil d’observabilité ou un pipeline de logs.

Échantillonner pour éviter de tout collecter

La mesure a elle-même un coût. Il n’est pas nécessaire d’envoyer toutes les métriques de tous les utilisateurs sur toutes les pages. Une stratégie courante consiste à échantillonner.

const SAMPLE_RATE = 0.1;

function shouldCollectMetrics() {
  return Math.random() < SAMPLE_RATE;
}

if (shouldCollectMetrics()) {
  // Initialiser ici les PerformanceObserver
}

Vous pouvez aussi augmenter temporairement le taux d’échantillonnage sur une nouvelle page, une nouvelle fonctionnalité ou une population spécifique d’utilisateurs, puis le réduire une fois les données stabilisées.

Bonnes pratiques d’interprétation

PerformanceObserver donne des signaux, pas des réponses automatiques. Une métrique doit toujours être interprétée dans son contexte : type d’appareil, route consultée, état du cache, connexion réseau, version de l’application, présence de scripts tiers.

Évitez également de transformer chaque mesure individuelle en alerte. Une valeur isolée très mauvaise peut venir d’un appareil ancien ou d’un réseau temporairement dégradé. Ce sont les distributions qui comptent : médiane, 75e percentile, 95e percentile, comparaison avant/après déploiement.

Il est aussi préférable de relier les métriques à des décisions concrètes. Si le LCP se dégrade après l’ajout d’une image héroïque, travaillez sur l’image. Si les long tasks augmentent après l’ajout d’un composant de datavisualisation, inspectez le rendu, la quantité de données et la possibilité d’utiliser un worker. Si le CLS augmente après l’ajout d’un bandeau marketing, réservez son espace dès le rendu initial.

Conclusion

PerformanceObserver est une API discrète mais puissante pour passer d’une optimisation ponctuelle à une véritable observabilité frontend. Elle permet de mesurer le chargement des ressources, les longues tâches JavaScript, le LCP, les décalages de mise en page et d’autres signaux essentiels.

Sa valeur ne réside pas dans la collecte brute de chiffres, mais dans la boucle qu’elle rend possible : mesurer, comprendre, corriger, vérifier. Pour une équipe web, c’est une base solide pour améliorer la performance réelle sans se limiter aux scores de laboratoire.