Retour au blog

La Transition d'État de Visibilité : Mesurer les Pages Vues Sans Beacons Fiables

pagehide, visibilitychange et la Beacon API se comportent différemment selon les navigateurs. Voici comment enregistrer la fin d'une session de façon fiable, sans cookies ni identifiants persistants.

Enregistrer qu'une page a été vue est facile. Enregistrer le moment où l'utilisateur est réellement parti — de façon fiable, à travers les changements d'onglet, les mises en arrière-plan et les fermetures brutales — est l'un des problèmes les plus épineux de l'analytics web. Cela compte parce que les métriques d'engagement (temps passé, rebond, profondeur de défilement) dépendent de la capture d'un signal propre de fin de session. Si vous vous trompez, vous perdez des données ou vous les gonflez.

Monoid résout cela sans cookies, sans localStorage et sans aucun identifiant persistant. Cette contrainte simplifie en réalité les choses : nous n'avons jamais besoin de réconcilier une session entre deux chargements de page, donc tout ce qui compte est d'émettre un seul échantillon final exact par cycle de vie de page. Voici comment se comportent les primitives du navigateur, où se trouvent les pièges, et laquelle de ces techniques le tracker de Monoid utilise réellement.

Pourquoi unload est le mauvais événement

L'approche historique consistait à écouter unload ou beforeunload et à déclencher un XHR synchrone. C'est aujourd'hui activement nuisible. L'événement unload n'est pas fiable sur mobile : lorsqu'un onglet passe en arrière-plan et que l'OS le récupère, unload ne se déclenche souvent jamais. Pire, enregistrer un gestionnaire unload disqualifie la page du cache avant/arrière (bfcache) dans plusieurs navigateurs, ce qui pénalise les performances de navigation.

Le guide de la Page Lifecycle API sur web.dev est explicite : traitez le passage de visibilitychange à hidden comme le dernier événement fiable que vous observerez. Ne comptez pas sur unload ni pagehide sur mobile.

Le duo visibilitychange + pagehide

Le motif robuste consiste à écouter visibilitychange et à vérifier document.visibilityState === 'hidden', avec pagehide comme signal secondaire. Dès que la page passe à l'état masqué, vous videz ce que vous avez à envoyer. La documentation MDN de la Page Visibility API confirme que visibilitychange se déclenche quand un onglet passe en arrière-plan ou que le navigateur est réduit — l'approximation la plus proche de « l'utilisateur a cessé de regarder ».

La complication : le passage à masqué peut se déclencher plusieurs fois dans une session (quitter l'onglet, revenir, repartir). Vous devez donc rendre votre envoi idempotent, le debouncer, ou réinitialiser votre propre horloge pour que chaque déclenchement rapporte un segment nouveau et sans chevauchement plutôt qu'un total croissant. Monoid adopte cette dernière approche : le chronomètre derrière son échantillon de temps passé se réinitialise à chaque transition de visibilitychange, si bien qu'un second événement hidden plus tard dans le même cycle de vie de page ne rapporte que le temps écoulé depuis le dernier envoi. Monoid écoute visibilitychange (aux côtés d'un repli beforeunload déjà existant) mais n'ajoute pas d'écouteur pagehide distinct — les deux se chevauchent suffisamment sur les cas qui comptent pour qu'un second écouteur ne fasse que réintroduire le problème de double comptage, sans couvrir un terrain sensiblement plus large.

Envoyer des données depuis une page mourante : navigator.sendBeacon

Vous ne pouvez pas déclencher un fetch asynchrone pendant visibilitychange et espérer qu'il aboutisse — le navigateur peut démonter la page avant. C'est précisément la raison d'être de la spécification de la Beacon API. navigator.sendBeacon(url, data) met en file un petit POST que le navigateur garantit de tenter même après la disparition du document, sans bloquer le thread principal ni retarder la navigation.

Beacon a des limites qu'il vaut mieux respecter :

  • Taille de la charge utile. La spécification autorise l'agent utilisateur à rejeter les beacons dépassant une limite définie par l'implémentation (souvent 64 Ko). Gardez des charges minuscules — celles de Monoid font quelques centaines d'octets.
  • Méthode. Les beacons sont toujours en POST. Si votre point de terminaison edge attend un GET, adaptez-le.
  • Pas de réponse. Vous ne pouvez rien relire. Envoyez et oubliez.

Une solution de repli moderne est fetch(url, { keepalive: true }), que le standard Fetch définit comme autorisant une requête à survivre au document. keepalive vous donne des en-têtes et des méthodes qui manquent à Beacon, mais partage le même plafond de taille. La sagesse habituelle dit d'utiliser Beacon d'abord et de se replier sur fetch avec keepalive — Monoid fait en réalité l'inverse, pour les raisons que la section suivante explique.

Une implémentation minimale et sans cookies

Voici un modèle de référence combinant tout ce qui précède — sendBeacon d'abord, fetch keepalive en repli, déclenché à la fois par visibilitychange et par pagehide, avec une garde pour qu'un déclenchement répété n'envoie pas deux fois :

let sent = false;
function flush() {
  if (sent) return;
  sent = true;
  const payload = JSON.stringify({
    path: location.pathname,
    // aucun identifiant, aucun cookie, aucune empreinte
    visibleMs: Math.round(performance.now())
  });
  const ok = navigator.sendBeacon('/collect', payload);
  if (!ok) {
    fetch('/collect', { method: 'POST', body: payload, keepalive: true });
  }
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);

Notez ce qui est absent : aucun écouteur unload, donc l'éligibilité au bfcache est préservée. Aucun identifiant stocké entre les chargements. La valeur visibleMs utilise performance.now(), une horloge monotone qui n'exige ni horodatage d'horloge murale ni état persistant.

Le tracker de Monoid emprunte une voie voisine mais distincte : fetch keepalive uniquement (pas de sendBeacon — il ne peut pas exprimer le mode: 'cors' dont une installation multi-origine a besoin), aucune garde sent, et une horloge qui se réinitialise à chaque transition de visibilité au lieu de se déclencher une fois puis de se taire (le filtrage du consentement et du Do Not Track est omis ci-dessous par souci de clarté) :

function dur() {
  fetch('/duration', {
    method: 'POST',
    body: JSON.stringify({ site_id: siteId, duration_ms: Date.now() - t }),
    headers: { 'Content-Type': 'application/json' },
    keepalive: true,
    mode: 'cors',
  });
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') dur();
  t = Date.now();
});
window.addEventListener('beforeunload', dur);

Réinitialiser t à chaque transition fait le même travail que la garde sent ci-dessus, sans avoir besoin d'un drapeau : chaque appel ne rapporte que le segment écoulé depuis la dernière réinitialisation de l'horloge, si bien qu'un déclenchement visibilitychange suivi d'un beforeunload lors de la même fermeture d'onglet rapporte un segment réel puis un second quasi nul et inoffensif, au lieu d'un double comptage.

Pourquoi cela convient à l'analytics privacy-first

Parce que Monoid ne recoud jamais les sessions entre elles, chaque requête est une observation complète et jetable. Il n'y a aucun identifiant inter-pages à protéger, donc un échantillon perdu ou dupliqué vous coûte un point de donnée, pas un profil utilisateur corrompu. Le motif visibilitychange plus beforeunload que Monoid livre réellement offre une mesure exacte du temps passé tout en gardant la page rapide et compatible bfcache — ce qui améliore en soi les Core Web Vitals que vous cherchez peut-être à mesurer.

Mesurez le départ, pas la personne. Le navigateur vous donne déjà tout ce dont vous avez besoin.

Sources

Comments

Loading comments…