Zurück zum Blog

Der Übergang des Sichtbarkeitszustands: Seitenaufrufe ohne verlässliche Beacons messen

pagehide, visibilitychange und die Beacon API verhalten sich in jedem Browser anders. So erfassen Sie das Ende einer Sitzung zuverlässig — ohne Cookies und ohne persistente Identifikatoren.

Zu erfassen, dass eine Seite aufgerufen wurde, ist leicht. Zu erfassen, wann Nutzerinnen und Nutzer tatsächlich gegangen sind — zuverlässig, über Tabwechsel, Hintergrundwechsel und harte Schließvorgänge hinweg — gehört zu den kniffligeren Problemen der Web-Analytik. Das ist wichtig, weil Engagement-Metriken (Verweildauer, Absprung, Scrolltiefe) davon abhängen, ein sauberes Signal für das Sitzungsende einzufangen. Macht man es falsch, verliert man Daten oder bläht sie auf.

Monoid löst das ohne Cookies, localStorage oder irgendeinen persistenten Identifikator. Diese Beschränkung vereinfacht die Sache sogar: Wir müssen eine Sitzung nie über Seitenaufrufe hinweg zusammenführen, es zählt also nur, pro Seitenlebenszyklus genau ein korrektes Abschluss-Sample zu senden. So verhalten sich die Browser-Primitive, hier liegen die Fallen, und das ist, welche davon Monoids eigener Tracker tatsächlich nutzt.

Warum unload das falsche Event ist

Historisch lauschte man auf unload oder beforeunload und feuerte ein synchrones XHR ab. Das ist heute aktiv schädlich. Das unload-Event ist auf Mobilgeräten unzuverlässig: Wenn ein Tab in den Hintergrund wandert und das Betriebssystem ihn zurückfordert, feuert unload häufig nie. Schlimmer noch: Ein registrierter unload-Handler disqualifiziert die Seite in mehreren Browsern vom Back/Forward-Cache (bfcache) und schadet damit der Navigationsleistung.

Die Anleitung zur Page Lifecycle API von web.dev ist eindeutig: Behandeln Sie den Übergang von visibilitychange zu hidden als das letzte verlässliche Event, das Sie beobachten werden. Verlassen Sie sich nicht darauf, dass unload oder pagehide auf Mobilgeräten feuern.

Das Paar visibilitychange + pagehide

Das robuste Muster ist, auf visibilitychange zu lauschen und document.visibilityState === 'hidden' zu prüfen, ergänzt um pagehide als sekundäres Signal. Sobald die Seite in den verborgenen Zustand wechselt, schreiben Sie alles Ausstehende weg. Die MDN-Dokumentation zur Page Visibility API bestätigt, dass visibilitychange feuert, wenn ein Tab in den Hintergrund geht oder der Browser minimiert wird — der beste verfügbare Näherungswert für „die Person schaut nicht mehr hin".

Die Komplikation: Der Wechsel zu verborgen kann in einer Sitzung mehrfach feuern (wegwechseln, zurückwechseln, wieder wegwechseln). Sie müssen Ihr Wegschreiben also idempotent machen, entprellen, oder Ihre eigene Uhr zurücksetzen, damit jeder Durchlauf ein frisches, nicht überlappendes Segment statt einer wachsenden Summe meldet. Monoid verfolgt Letzteres: Der Timer hinter dem Verweildauer-Sample wird bei jedem visibilitychange-Übergang zurückgesetzt, sodass ein späteres zweites hidden-Event im selben Seitenlebenszyklus nur die Zeit seit dem letzten Wegschreiben meldet. Monoid lauscht auf visibilitychange (neben einem bereits vorhandenen beforeunload-Fallback), fügt aber keinen separaten pagehide-Listener hinzu — beide überschneiden sich auf den relevanten Pfaden ausreichend, sodass ein zweiter Listener das Problem der Doppelzählung nur erneut einführen würde, ohne nennenswert mehr Fälle abzudecken.

Daten von einer sterbenden Seite senden: navigator.sendBeacon

Sie können während visibilitychange kein asynchrones fetch starten und erwarten, dass es abgeschlossen wird — der Browser darf die Seite vorher abbauen. Genau dafür existiert die Spezifikation der Beacon API. navigator.sendBeacon(url, data) stellt ein kleines POST in die Warteschlange, dessen Versuch der Browser auch nach dem Ende des Dokuments garantiert — ohne den Haupt-Thread zu blockieren oder die Navigation zu verzögern.

Beacon hat Grenzen, die es zu respektieren lohnt:

  • Nutzlastgröße. Die Spezifikation erlaubt dem User Agent, Beacons oberhalb einer implementierungsdefinierten Grenze (üblicherweise 64 KB) abzulehnen. Halten Sie Nutzlasten winzig — die von Monoid sind wenige hundert Bytes groß.
  • Methode. Beacons sind immer POST. Wenn Ihr Edge-Endpunkt GET erwartet, passen Sie ihn an.
  • Keine Antwort. Sie können nichts zurücklesen. Abschicken und vergessen.

Eine moderne Ausweichlösung ist fetch(url, { keepalive: true }), das der Fetch-Standard so definiert, dass eine Anfrage das Dokument überleben darf. keepalive gibt Ihnen Header und Methoden, die Beacon fehlen, teilt aber dieselbe Größenobergrenze. Die gängige Faustregel sagt: erst Beacon, dann Ausweichen auf fetch mit keepalive — Monoid dreht das tatsächlich um, aus Gründen, die der nächste Abschnitt erklärt.

Eine minimale, cookiefreie Implementierung

Hier ein Referenzmuster, das alles oben Genannte kombiniert — zuerst sendBeacon, fetch keepalive als dessen Fallback, ausgelöst sowohl durch visibilitychange als auch durch pagehide, mit einer Sperre, damit ein wiederholtes Feuern nicht doppelt sendet:

let sent = false;
function flush() {
  if (sent) return;
  sent = true;
  const payload = JSON.stringify({
    path: location.pathname,
    // keine Identifikatoren, keine Cookies, kein Fingerprint
    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);

Beachten Sie, was fehlt: kein unload-Listener, damit bleibt die bfcache-Eignung erhalten. Keine über Seitenaufrufe gespeicherte ID. Der Wert visibleMs nutzt performance.now(), eine monotone Uhr, die weder einen Wanduhr-Zeitstempel noch persistenten Zustand braucht.

Monoids eigener Tracker geht einen verwandten, aber anderen Weg: nur fetch keepalive (kein sendBeacon — es kann das mode: 'cors' nicht ausdrücken, das eine Installation über Ursprünge hinweg braucht), keine sent-Sperre, und ein Timer, der bei jedem Sichtbarkeitswechsel zurückgesetzt wird, statt einmal zu feuern und dann zu schweigen (Consent- und Do-Not-Track-Prüfung sind unten der Übersichtlichkeit halber weggelassen):

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);

Das Zurücksetzen von t bei jedem Übergang erledigt denselben Job wie oben die sent-Sperre, nur ohne ein Flag zu brauchen: Jeder Aufruf meldet nur das Segment seit dem letzten Zurücksetzen der Uhr, sodass ein visibilitychange-Feuern gefolgt von einem beforeunload beim selben Tab-Schließen ein echtes Segment und ein harmloses Beinahe-Null-Segment meldet, statt doppelt zu zählen.

Warum das zu Privacy-First-Analytik passt

Weil Monoid Sitzungen nie zusammennäht, ist jede Anfrage eine vollständige, wegwerfbare Beobachtung. Es gibt keinen seitenübergreifenden Identifikator zu schützen, eine verlorene oder doppelte Probe kostet Sie also einen Datenpunkt und kein beschädigtes Nutzerprofil. Das Muster aus visibilitychange plus beforeunload, das Monoid tatsächlich ausliefert, liefert eine genaue Verweildauermessung und hält die Seite zugleich schnell und bfcache-freundlich — was selbst die Core Web Vitals verbessert, die Sie womöglich messen wollen.

Messen Sie das Gehen, nicht die Person. Der Browser gibt Ihnen bereits alles, was Sie brauchen.

Sources

Comments

Loading comments…