navigator.sendBeacon: Reliable API Requests Even When the Tab Closes
Learn how to reliably send API requests when a tab closes using navigator.sendBeacon, fetch keepalive, and a 3-stage fallback strategy with Service Worker sync.
navigator.sendBeacon: Reliable API Requests Even When the Tab Closes
If you've ever tried to fire an analytics ping or save session data the moment a user closes a tab, you already know the pain. Your fetch() call gets cancelled. Your XHR goes nowhere. The data vanishes into the void — and you have no idea it even happened.
navigator.sendBeacon() was built to solve exactly this problem. But the raw API is just the starting point. In this post, we'll go beyond the basics and cover the real-world patterns that production apps need: a three-stage fallback strategy combining sendBeacon, visibilitychange, and pagehide; how to dodge CORS headaches with the right payload type; a decision tree for choosing between sendBeacon and fetch keepalive; and an architecture that combines Service Worker Background Sync to survive offline conditions. We'll also look at how to use the same reliability guarantees from ky and fetch.
The Three-Stage Fallback Strategy: visibilitychange + pagehide + sendBeacon
Relying on a single event to catch "user is leaving" is a recipe for data loss. Browser behavior varies across Chrome, Safari, and Firefox — especially on mobile. A robust production pattern layers three triggers.
Why Three Stages?
visibilitychange(visibilityState === 'hidden') fires earliest and most reliably — including when the user switches tabs, locks their phone screen, or backgrounds the app. This is your best first opportunity to flush pending data.pagehideis the modern replacement forunload, firing when the page is being removed from the session history. It works on both desktop and mobile Safari (which notoriously misfiresunload).beforeunloadis a last-ditch option for desktop browsers — but avoid attaching a return value, or you'll trigger a confirmation dialog.
Production-Ready Three-Stage Fallback Code
const BEACON_URL = 'https://analytics.example.com/collect';
interface AnalyticsPayload {
sessionId: string;
events: object[];
timestamp: number;
}
let pendingPayload: AnalyticsPayload | null = null;
function flushPayload(payload: AnalyticsPayload): boolean {
// Serialize as text/plain Blob to avoid CORS preflight (explained below)
const blob = new Blob([JSON.stringify(payload)], { type: 'text/plain' });
return navigator.sendBeacon(BEACON_URL, blob);
}
function handleVisibilityChange() {
if (document.visibilityState === 'hidden' && pendingPayload) {
const success = flushPayload(pendingPayload);
if (!success) {
// sendBeacon returned false — queue to localStorage for next session
persistToLocalStorage(pendingPayload);
} else {
pendingPayload = null;
}
}
}
function handlePageHide(event: PageTransitionEvent) {
// event.persisted = true means page went into bfcache, not unloaded
if (!event.persisted && pendingPayload) {
const success = flushPayload(pendingPayload);
if (!success) {
persistToLocalStorage(pendingPayload);
}
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('pagehide', handlePageHide);
// Replay any data left over from the previous session
replayFromLocalStorage();
The key insight here is ordering: visibilitychange fires first and clears pendingPayload. If it succeeds, pagehide finds nothing to send and skips — preventing duplicate events. If visibilitychange fires but the beacon fails, pagehide gets a second chance.
CORS and Payload Types: Why text/plain Blob Beats application/json
This is the detail most tutorials skip, and it causes silent failures in production.
The CORS Preflight Problem
When you call navigator.sendBeacon(url, JSON.stringify(data)) with a plain string, the browser sets the Content-Type to text/plain;charset=UTF-8 — a simple request under CORS rules, which does not trigger a preflight OPTIONS request.
But if you try to send an application/json body — either by passing a Blob with that type or through a fetch — the browser must send a preflight first. For a beacon fired during page unload, that preflight either never completes or gets cancelled outright, meaning your actual data never arrives.
Three Payload Options Compared
// ❌ Triggers CORS preflight — risky during unload
const jsonBlob = new Blob([JSON.stringify(data)], { type: 'application/json' });
navigator.sendBeacon(url, jsonBlob);
// ✅ No preflight — simple request
const textBlob = new Blob([JSON.stringify(data)], { type: 'text/plain' });
navigator.sendBeacon(url, textBlob);
// ✅ No preflight — FormData is also a simple request type
const formData = new FormData();
formData.append('payload', JSON.stringify(data));
navigator.sendBeacon(url, formData);
Server-Side Handling
Your backend needs to parse the body accordingly. With text/plain, you read the raw body and JSON.parse() it manually. With FormData, you access request.body.payload (or equivalent). Neither is difficult — and the tradeoff is well worth it for the reliability gain.
// Express.js example for text/plain beacon
app.post('/collect', (req, res) => {
let body = '';
req.on('data', chunk => (body += chunk));
req.on('end', () => {
const data = JSON.parse(body);
// process data...
res.sendStatus(204);
});
});
Decision Tree: sendBeacon vs. fetch keepalive vs. Regular fetch
Not every "fire on close" scenario is the same. Here's how to pick the right tool.
Do you need to READ the response?
├─ Yes → Use fetch() (regular, with keepalive: true if near unload)
└─ No ↓
Do you need custom headers (e.g., Authorization, X-API-Key)?
├─ Yes → Use fetch({ keepalive: true }) — sendBeacon cannot set headers
└─ No ↓
Is payload larger than 64 KB?
├─ Yes → Use fetch({ keepalive: true }) — sendBeacon enforces a 64 KB limit
└─ No ↓
Are you inside a visibilitychange / pagehide handler?
├─ Yes → Use navigator.sendBeacon() — browser-guaranteed delivery
└─ No → Regular fetch() is fine
When fetch keepalive Makes Sense
fetch with keepalive: true tells the browser to keep the request alive even after the page is gone — similar to sendBeacon, but with the full fetch API surface.
// fetch keepalive — when you need headers or a response
async function sendWithKeepalive(payload: object) {
try {
const response = await fetch('https://api.example.com/events', {
method: 'POST',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify(payload),
});
return response.ok;
} catch {
return false;
}
}
Note: fetch keepalive also has a payload budget — 64 KB across all in-flight keepalive requests per origin in Chrome. So for very large payloads, you're better off batching or using a server-sent summary rather than the full dataset.
Using the Same Pattern with ky
ky is a popular fetch wrapper with retry and hook support. You can pass keepalive through its options:
import ky from 'ky';
async function sendWithKy(payload: object) {
return ky.post('https://api.example.com/events', {
json: payload,
// ky passes through fetch init options
keepalive: true,
retry: 0, // don't retry on unload — page may already be gone
hooks: {
beforeError: [
error => {
persistToLocalStorage(payload);
return error;
},
],
},
});
}
The keepalive: true flag flows through ky directly to the underlying fetch call, giving you the same delivery guarantees with ky's ergonomic API on top.
sendBeacon + Service Worker Background Sync: Surviving Offline
sendBeacon is reliable when the network is available — but what happens if the user closes the tab mid-flight on a flaky connection? The beacon is queued, the network drops, and the data is lost.
Background Sync API — available in Service Workers — fills this gap. It lets you queue work that the browser will execute when connectivity is restored, even if the page is long gone.
Architecture Overview
Page (tab closing)
│
├─ navigator.sendBeacon() → succeeds on good network ✅
│
└─ fails / network unavailable
│
├─ IndexedDB: persist payload
│
└─ serviceWorker.ready → sync.register('send-analytics')
│
└─ Service Worker 'sync' event fires when online
│
└─ fetch() from IndexedDB → server ✅
Implementation
In your page:
async function sendWithBackgroundSyncFallback(payload: object) {
const blob = new Blob([JSON.stringify(payload)], { type: 'text/plain' });
const sent = navigator.sendBeacon('/collect', blob);
if (!sent) {
await saveToIndexedDB('pending-beacons', payload);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('send-analytics');
}
}
In your Service Worker (sw.ts):
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'send-analytics') {
event.waitUntil(replayPendingBeacons());
}
});
async function replayPendingBeacons() {
const pending = await loadFromIndexedDB('pending-beacons');
for (const payload of pending) {
const response = await fetch('/collect', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }, // headers are fine in SW
});
if (response.ok) {
await removeFromIndexedDB('pending-beacons', payload.id);
}
}
}
Inside a Service Worker, you're not constrained by the CORS preflight timing issue — the page is already gone and the SW has its own lifecycle. So you can safely use application/json headers here, making server-side parsing straightforward.
Browser Support Note
Background Sync is supported in Chrome and Edge but not in Firefox or Safari as of 2024. For those browsers, the localStorage fallback (next section) is your safety net.
When sendBeacon Returns false: The localStorage Retry Pattern
navigator.sendBeacon() returns a boolean. true means the browser accepted the request for queuing. false means it refused — typically because the payload exceeded 64 KB, the browser tab limit was hit, or the user agent decided not to queue it.
Most code ignores this return value. Don't.
localStorage as a Cross-Session Safety Net
const STORAGE_KEY = 'beacon_retry_queue';
function persistToLocalStorage(payload: object): void {
const existing = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
existing.push({
id: crypto.randomUUID(),
timestamp: Date.now(),
data: payload,
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(existing));
}
function replayFromLocalStorage(): void {
const queue = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (queue.length === 0) return;
const remaining = [];
for (const item of queue) {
// Drop stale entries older than 24 hours
if (Date.now() - item.timestamp > 86_400_000) continue;
const blob = new Blob([JSON.stringify(item.data)], { type: 'text/plain' });
const sent = navigator.sendBeacon('/collect', blob);
if (!sent) {
remaining.push(item); // still failing — keep for next session
}
}
if (remaining.length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(remaining));
} else {
localStorage.removeItem(STORAGE_KEY);
}
}
// Call on app startup
replayFromLocalStorage();
This pattern creates a cross-session retry loop: failed beacons persist locally, and the next time the user opens the app (even hours later), the data gets a second chance at delivery.
Two important guardrails to include:
- Stale entry pruning: Drop anything older than a reasonable window (24 hours works for analytics). Otherwise, your queue grows indefinitely.
- Max queue size: Cap the array length to prevent localStorage from filling up if the user is perpetually offline.
Conclusion: Build a Bulletproof Data Pipeline at Page Close
The gap between "it usually works" and "it always works" is exactly where lost analytics, incomplete sessions, and corrupted audit logs live. Here's the full battle-tested stack to close that gap:
- Layer your triggers:
visibilitychangefirst,pagehideas backup — don't rely on a single event. - Use
text/plainBlob orFormDatato avoid CORS preflight failures during unload. - Choose the right tool:
sendBeaconfor fire-and-forget under 64 KB,fetch keepalivewhen you need headers or a response. - Layer in Background Sync for offline resilience where browser support allows.
- Always check the return value of
sendBeaconand fall back tolocalStoragefor cross-session retry.
If you're using ky or a custom fetch wrapper, keepalive: true gives you most of the same guarantees with the full API surface — just be mindful of the header-triggered CORS preflight at unload time.
Start with the three-stage fallback pattern, add the localStorage retry on day one, and layer in Background Sync when your user base and browser matrix justify it. Your data pipeline will thank you.
Comments
Loading comments...