Daten beim Seitenende zuverlässig senden
Fetch und XHR beim Seitenende zu nutzen ist eine Race-Condition gegen den Browser. Wenn der Tab geschlossen oder die Seite entladen wird, bricht der Browser offene Anfragen ab. navigator.sendBeacon überträgt Daten garantiert, ohne die Navigation zu verzögern – das ist die richtige Lösung für Analytics, Session-Daten und Exit-Tracking.
Inhaltsverzeichnis
- 1. Das Problem: Warum fetch beim Seitenende versagt
- 2. Wie navigator.sendBeacon funktioniert
- 3. Syntax, Rückgabewert und Payload-Typen
- 4. Der richtige Auslöser: visibilitychange statt beforeunload
- 5. CORS-Verhalten der Beacon API
- 6. Payload-Limits und Fehlerbehandlung
- 7. fetch keepalive als moderne Alternative
- 8. sendBeacon vs. fetch keepalive vs. XHR im Vergleich
- 9. Praxisbeispiel: robuste Analytics-Queue mit sendBeacon
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem: Warum fetch beim Seitenende versagt
Jeder Browser-Tab hat einen Lebenszyklus: er wird geladen, ist aktiv, wird möglicherweise in den Hintergrund verschoben und wird schließlich geschlossen oder navigiert zur nächsten Seite. Das Problem: Wenn der Browser eine Seite entlädt – durch Tab-Schließen, Navigation oder Refresh – bricht er alle offenen HTTP-Anfragen ab. Fetch-Anfragen, die in einem beforeunload-Handler gestartet werden, kommen häufig nicht beim Server an, weil der Prozess abgebrochen wird, bevor die Verbindung vollständig hergestellt ist. Das ist keine Browser-Bug, sondern korrektes Verhalten: Der Browser priorisiert die Navigation über ausstehende Netzwerkoperationen.
In der Praxis bedeutet das: Analytics-Events für Absprünge, Session-Duration-Metriken, Form-Abandonment-Daten und Exit-Survey-Antworten gehen regelmäßig verloren, wenn sie über normale Fetch- oder XHR-Anfragen in beforeunload-Handlern gesendet werden. Der alte Workaround – einen synchronen XHR mit xhr.open('POST', url, false) zu stellen – blockiert die Navigation um die gesamte Netzwerkrunde-Zeit und ist in modernen Browsern für viele Kontexte gesperrt. navigator.sendBeacon wurde entwickelt, um genau dieses Problem zu lösen, ohne die Performance oder Nutzererfahrung zu beeinträchtigen.
2. Wie navigator.sendBeacon funktioniert
navigator.sendBeacon übergibt eine HTTP-Anfrage an den Browser-Networking-Stack mit der Garantie, dass die Anfrage gesendet wird, auch wenn die auslösende Seite bereits entladen wurde. Der Browser behandelt Beacon-Anfragen wie eine Queue: Sie werden vom Seitenkontext getrennt und unabhängig vom Lifecycle der Seite verarbeitet. Die auslösende Seite wird nicht geblockt – navigator.sendBeacon kehrt synchron zurück und die eigentliche Netzwerkoperation läuft asynchron im Hintergrund.
Intern nutzt der Browser dieselben Mechanismen wie für reguläre HTTP-Anfragen – HTTP/2-Multiplexing, Keep-Alive-Verbindungen, TLS-Session-Reuse. Der Unterschied liegt in der Priorisierung: Beacon-Anfragen werden mit niedriger Netzwerkpriorität gesendet (ähnlich wie fetch({priority: 'low'})), da sie keine Antwort benötigen und die Nutzerinteraktion nicht beeinflussen sollen. Die Anfrage ist immer ein HTTP-POST. Es gibt keinen Response-Callback – der Browser ignoriert die Server-Antwort vollständig. Das macht navigator.sendBeacon für fire-and-forget-Datenübertragungen ideal, aber ungeeignet für Anfragen, die eine Server-Antwort benötigen.
3. Syntax, Rückgabewert und Payload-Typen
navigator.sendBeacon hat eine einfache Signatur: navigator.sendBeacon(url, data). Der Rückgabewert ist ein Boolean: true, wenn der Browser die Anfrage erfolgreich in die Queue aufgenommen hat, und false, wenn die Queue voll ist oder der Browser die Anfrage abgelehnt hat (z.B. weil die Payload zu groß ist). Ein true-Rückgabewert bedeutet nicht, dass der Server die Anfrage erhalten hat – es bedeutet nur, dass der Browser sie versuchen wird zu senden.
Der data-Parameter akzeptiert mehrere Typen: Blob, ArrayBufferView, FormData, URLSearchParams und DOMString (Plain String). Der häufigste Einsatz ist ein JSON-String, der als Blob mit explizit gesetztem Content-Type übergeben wird: new Blob([JSON.stringify(data)], { type: 'application/json' }). Wenn man stattdessen einen Plain String übergibt, setzt der Browser den Content-Type auf text/plain;charset=UTF-8. Für Server, die JSON erwarten, ist die Blob-Variante die korrekte Methode, um den Content-Type zu kontrollieren.
// navigator.sendBeacon — basic usage and payload types
const endpoint = '/api/analytics/beacon';
// 1. JSON payload as Blob with explicit Content-Type
function sendBeaconJSON(data) {
const payload = new Blob([JSON.stringify(data)], {
type: 'application/json',
});
const success = navigator.sendBeacon(endpoint, payload);
if (!success) {
console.warn('[beacon] Queue full or request rejected — fallback to fetch');
// Fallback for critical data
fetch(endpoint, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
keepalive: true, // fetch keepalive as alternative
}).catch(() => {}); // ignore errors on page unload
}
return success;
}
// 2. FormData payload (no Content-Type control needed)
function sendBeaconFormData(fields) {
const form = new FormData();
for (const [key, value] of Object.entries(fields)) {
form.append(key, value);
}
return navigator.sendBeacon(endpoint, form);
}
// 3. URLSearchParams (simple key-value pairs)
function sendBeaconParams(params) {
const body = new URLSearchParams(params);
return navigator.sendBeacon(endpoint, body);
}
4. Der richtige Auslöser: visibilitychange statt beforeunload
Das beforeunload-Event ist die intuitive Wahl für Seitenende-Tracking, aber es ist aus zwei Gründen problematisch: Erstens feuert es nicht bei allen Seitenende-Szenarien – insbesondere nicht bei mobilen Browsern, die Tabs aggressiv suspendieren, und nicht bei Navigation über den Browser-Back-Button in manchen Implementierungen. Zweitens hindert ein beforeunload-Handler mit einem returnValue den Browser daran, Back-Forward-Cache (bfcache) zu nutzen, was die Navigation deutlich langsamer macht.
Die empfohlene Alternative für navigator.sendBeacon-Aufrufe ist das visibilitychange-Event in Kombination mit document.visibilityState === 'hidden'. Dieses Event feuert, wenn der Tab in den Hintergrund wechselt (Nutzer wechselt zu anderem Tab), wenn das Mobilgerät den Bildschirm sperrt und wenn die Seite geschlossen oder navigiert wird. Es ist konsistenter als beforeunload und beeinflusst den bfcache nicht. Google Analytics 4 und die Web Vitals-Bibliothek nutzen explizit visibilitychange statt beforeunload für ihre Beacon-Calls.
// Correct trigger pattern: visibilitychange instead of beforeunload
const sessionData = {
startTime: Date.now(),
events: [],
path: window.location.pathname,
};
function flushSession() {
if (sessionData.events.length === 0) return;
const payload = {
...sessionData,
endTime: Date.now(),
duration: Date.now() - sessionData.startTime,
};
const blob = new Blob([JSON.stringify(payload)], {
type: 'application/json',
});
// Send beacon — guaranteed delivery even when page is being unloaded
const sent = navigator.sendBeacon('/api/session/end', blob);
if (sent) {
sessionData.events = []; // Reset after successful queue
}
}
// Preferred: fires on tab switch, mobile lock, navigation and close
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushSession();
}
});
// Additional safety net for desktop browsers
window.addEventListener('pagehide', flushSession, { once: true });
// Track user interactions for session data
document.addEventListener('click', (e) => {
sessionData.events.push({
type: 'click',
target: e.target.tagName,
timestamp: Date.now(),
});
});
5. CORS-Verhalten der Beacon API
Das CORS-Verhalten von navigator.sendBeacon ist ein wichtiger, oft missverstandener Aspekt. Beacon-Anfragen folgen denselben CORS-Regeln wie reguläre Fetch-Anfragen, aber mit einer entscheidenden Einschränkung: navigator.sendBeacon sendet immer einen einfachen POST-Request ohne Custom-Header. Wenn die Payload ein Blob mit application/json als Content-Type ist, handelt es sich um eine CORS-Preflight-Anfrage – d.h. der Browser sendet zuerst eine OPTIONS-Anfrage, bevor die eigentliche Beacon-Anfrage gesendet wird.
Für CORS-freie Beacon-Anfragen empfiehlt sich die Verwendung von text/plain als Content-Type (kein Preflight erforderlich) oder application/x-www-form-urlencoded via URLSearchParams. Wenn der Beacon-Endpoint auf demselben Origin wie die Seite liegt (Same-Origin), gibt es keine CORS-Einschränkungen. Für Cross-Origin-Tracking-Endpoints – z.B. wenn ein Analytics-Service auf einer anderen Domain liegt – muss der Server korrekte CORS-Header zurückgeben: Access-Control-Allow-Origin und Access-Control-Allow-Methods: POST. Die Preflight-Verzögerung kann problematisch sein, wenn die Seite schnell entladen wird, bevor die OPTIONS-Antwort eintrifft.
6. Payload-Limits und Fehlerbehandlung
Die Beacon API hat ein Payload-Limit, das browser-spezifisch ist und typischerweise bei 64 KB liegt. Wenn die Payload dieses Limit überschreitet, gibt navigator.sendBeacon false zurück, ohne die Anfrage zu senden. Das ist das einzige synchrone Feedback, das die API bietet. Eine erfolgreiche Queue-Aufnahme (true-Rückgabewert) gibt keine Garantie über den Lieferstatus – Netzwerkfehler, Server-Timeouts und 5xx-Antworten werden vom Browser ignoriert und dem JavaScript nicht gemeldet.
Für Anwendungen, bei denen Datenverlust inakzeptabel ist, gibt es drei Strategien: Erstens, Daten in localStorage oder IndexedDB zwischenspeichern und beim nächsten Seitenaufruf via reguläres Fetch senden – das sogenannte „Dead-drop"-Muster. Zweitens, Daten in kleinen Batches senden, die das 64-KB-Limit nicht überschreiten. Drittens, als Fallback fetch({keepalive: true}) verwenden, das ähnliche Garantien wie navigator.sendBeacon bietet, aber volle Kontrolle über Headers und Methode gibt. Die Kombination aus sendBeacon als primärer Methode und localStorage als sekundärem Speicher ist die robusteste Lösung für kritische Tracking-Daten.
7. fetch keepalive als moderne Alternative
fetch({keepalive: true}) ist eine modernere Alternative zu navigator.sendBeacon, die in allen aktuellen Browsern verfügbar ist. Im Gegensatz zur Beacon API erlaubt fetch keepalive vollständige Kontrolle über HTTP-Method, Headers und Request-Body. Es unterstützt alle HTTP-Methoden (nicht nur POST), ermöglicht Custom-Header (Authentication, X-API-Key), und gibt ein Promise zurück, das theoretisch ausgewertet werden kann – obwohl das im Kontext des Seitenendes praktisch nicht möglich ist, da der JavaScript-Kontext nicht mehr aktiv ist.
Der entscheidende Vorteil von navigator.sendBeacon gegenüber fetch keepalive: sendBeacon hat keine Einschränkungen bezüglich des aufrufenden Kontexts. Fetch keepalive kann in Service Workers und manchen Browser-Kontexten nicht funktionieren. Für die meisten Analytics-Anwendungsfälle sind beide Methoden äquivalent. Die Best-Practice: navigator.sendBeacon als primäre Methode verwenden, fetch keepalive als Fallback, wenn sendBeacon false zurückgibt oder nicht verfügbar ist.
8. sendBeacon vs. fetch keepalive vs. XHR im Vergleich
Die Wahl der richtigen Methode für Seitenende-Datenübertragungen hängt von den spezifischen Anforderungen ab. navigator.sendBeacon ist die einfachste und fokussierteste Lösung: keine Response, immer POST, breite Browserunterstützung, minimaler Code. Fetch keepalive ist flexibler: jede HTTP-Methode, Custom-Headers, Response-Promise. Synchroner XHR ist in modernen Browsern in vielen Kontexten gesperrt, blockiert die Navigation und sollte in keinem neuen Code verwendet werden.
Der wichtigste Unterschied in der Praxis: navigator.sendBeacon ist für Anwendungsfälle ohne Response-Anforderung optimiert. Wenn der Endpoint nur einen 204-Status zurückgibt und keine weitere Verarbeitung der Response nötig ist, ist sendBeacon die optimale Wahl. Wenn der Endpoint Authentifizierung über Custom-Headers benötigt, ist fetch keepalive die bessere Option. Beide Methoden haben dasselbe 64-KB-Limit-Problem, das nur durch clientseitiges Batching oder serverseitiges Streaming gelöst werden kann.
| Methode | Seitenende-Zuverlässigkeit | Custom Headers | Response lesbar |
|---|---|---|---|
| navigator.sendBeacon | Hoch (Browser-Queue) | Nein | Nein |
| fetch keepalive: true | Hoch (ähnlich Beacon) | Ja | Theoretisch (praktisch nicht) |
| fetch (normal) | Niedrig (abgebrochen) | Ja | Ja |
| Sync XHR | Mittel (blockiert UI) | Ja | Ja |
9. Praxisbeispiel: robuste Analytics-Queue mit sendBeacon
Eine produktionsreife Analytics-Queue mit navigator.sendBeacon kombiniert drei Mechanismen: Batch-Accumulation (Events werden gesammelt, nicht sofort gesendet), regelmäßiges Flush-Scheduling (z.B. alle 30 Sekunden via requestIdleCallback), und garantiertes Flush beim Seitenende via visibilitychange. Das localStorage-Backup fängt den Fall ab, dass der Browser die Beacon-Queue löscht, bevor die Anfrage versendet wurde – ein seltener, aber möglicher Fall bei Browser-Crashes oder erzwungenen Terminierungen.
Der Server-Endpoint für navigator.sendBeacon muss eine Besonderheit berücksichtigen: Der Browser erwartet eine schnelle Antwort, aber er verarbeitet sie nicht. Ein 204-Status ohne Body ist ideal. Der Endpoint sollte die empfangenen Daten asynchron in eine Queue schreiben und sofort antworten – ohne auf Datenbank-Writes oder komplexe Verarbeitung zu warten. Das entspricht dem CQRS-Pattern auf Server-Seite: Commands (Beacon-Daten) werden empfangen und asynchron verarbeitet, die HTTP-Response bestätigt nur den Empfang.
// Production analytics queue using sendBeacon with localStorage backup
class BeaconQueue {
constructor(endpoint, options = {}) {
this.endpoint = endpoint;
this.queue = this.restoreFromStorage(); // Recover unsent events
this.maxSize = options.maxSize ?? 50;
this.flushInterval = options.flushInterval ?? 30000;
this.storageKey = options.storageKey ?? 'beacon_queue';
// Periodic flush via idle scheduling
this.scheduleFlush();
// Guaranteed flush on page hide
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.flushSync();
});
window.addEventListener('pagehide', () => this.flushSync(), { once: true });
}
push(event) {
this.queue.push({ ...event, queuedAt: Date.now() });
this.saveToStorage();
// Flush immediately if queue is full
if (this.queue.length >= this.maxSize) {
this.flushSync();
}
}
flushSync() {
if (this.queue.length === 0) return true;
const batch = this.queue.splice(0, this.maxSize);
const blob = new Blob([JSON.stringify(batch)], {
type: 'application/json',
});
const sent = navigator.sendBeacon(this.endpoint, blob);
if (sent) {
// Clear storage for sent events
this.saveToStorage();
} else {
// Put events back — sendBeacon rejected (queue full)
this.queue.unshift(...batch);
console.warn('[beacon] Queue full — events preserved in storage');
}
return sent;
}
scheduleFlush() {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
this.flushSync();
setTimeout(() => this.scheduleFlush(), this.flushInterval);
}, { timeout: this.flushInterval });
} else {
setTimeout(() => { this.flushSync(); this.scheduleFlush(); }, this.flushInterval);
}
}
saveToStorage() {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.queue));
} catch {
/* Storage might be full or unavailable */
}
}
restoreFromStorage() {
try {
const stored = localStorage.getItem(this.storageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
}
// Initialization
const analytics = new BeaconQueue('/api/analytics/batch', {
maxSize: 30,
flushInterval: 20000,
});
analytics.push({ event: 'page_view', path: location.pathname });
10. Zusammenfassung
navigator.sendBeacon löst das Problem der unzuverlässigen Datenübertragung beim Seitenende zuverlässig und ohne Performance-Kompromisse. Der Browser übernimmt die Verantwortung für die Übertragung, unabhängig davon, ob die Seite bereits entladen wurde. visibilitychange ist der zuverlässigere Auslöser als beforeunload, weil es konsistenter feuert und den bfcache nicht blockiert. Das Blob-Muster mit explizitem Content-Type ist die korrekte Methode, um JSON-Daten zu übertragen.
Die wichtigsten Einschränkungen: Kein Response-Callback, immer POST, 64-KB-Payload-Limit, kein Custom-Header-Support. Für Anwendungsfälle, die Custom-Headers oder andere HTTP-Methoden benötigen, ist fetch({keepalive: true}) die Alternative. Das localStorage-Backup-Muster ist die robuste Ergänzung für Szenarien, in denen auch navigator.sendBeacon ausfallen kann. Eine produktionsreife Analytics-Queue kombiniert sendBeacon, requestIdleCallback für periodisches Flushing und localStorage als persistenten Zwischenspeicher für garantierte Datenlieferung.
Mironsoft
JavaScript Analytics, Tracking-Infrastruktur und Browser-APIs
Analytics-Daten, die garantiert ankommen?
Wir analysieren eure bestehende Analytics-Implementierung auf Datenverluste beim Seitenende, ersetzen fragile fetch/XHR-Patterns durch sendBeacon-basierte Queues und bauen robuste localStorage-Backups für kritische Event-Daten.
Analytics-Audit
beforeunload-Hacks und unsichere fetch-Calls beim Seitenende identifizieren
Queue-Implementierung
Robuste Beacon-Queue mit localStorage-Backup und rIC-Scheduling bauen
Server-Endpoint
Schnelle 204-Endpoints mit asynchroner Verarbeitung für Beacon-Payloads
navigator.sendBeacon — Das Wichtigste auf einen Blick
Browser-Queue-Garantie
sendBeacon übergibt die Anfrage an den Browser-Networking-Stack. Die Übertragung läuft auch nach dem Entladen der Seite weiter – unabhängig vom Lifecycle des JavaScript-Kontexts.
Richtiger Auslöser
visibilitychange + document.visibilityState === 'hidden' ist zuverlässiger als beforeunload. Kein bfcache-Blockieren, konsistentes Verhalten auf Mobilgeräten.
Payload-Pattern
JSON als Blob mit Content-Type 'application/json'. false-Rückgabe = Queue voll oder Payload über 64 KB. Fetch keepalive als Fallback für Custom-Header-Anforderungen.
localStorage-Backup
Gesendete Events erst nach erfolgreichem sendBeacon aus Storage löschen. Beim nächsten Seitenaufruf ungesendete Events via reguläres Fetch nachholen.
11. FAQ: navigator.sendBeacon
1Was ist navigator.sendBeacon?
2Warum ist fetch beim Seitenende unzuverlässig?
3Was bedeutet der Boolean-Rückgabewert?
4visibilitychange besser als beforeunload?
5JSON mit sendBeacon senden?
new Blob([JSON.stringify(data)], {type: 'application/json'}). Plain String setzt Content-Type auf text/plain.