
The Day My Analytics Finally Matched My Logs: My Quest for the Perfect Page-Unload Strategy
Exposing why your exit-intent tracking is silently failing and how the modern fetch keepalive flag provides the reliability the Beacon API never could.
I used to stare at my Grafana dashboards for hours, convinced I was losing my mind. My server logs would show a healthy stream of disconnects, but my custom analytics—the part that was supposed to tell me *why* users were leaving or how long they actually stayed—looked like a Swiss cheese of missing data. It felt like 30% of my users were simply vanishing into a digital void the moment they clicked the "X" on their browser tab.
The culprit? I was trusting the browser to be polite. I assumed that if I asked it to send one last bit of data on the way out, it would oblige. Spoiler alert: The browser doesn't care about your data; it cares about dying as fast as possible to free up memory.
The "Sync XHR" Dark Ages
Years ago, we handled this by forcing a synchronous XMLHttpRequest in the unload event. It worked, but it was a user experience nightmare. It would literally freeze the UI, preventing the tab from closing until the server responded. It was the digital equivalent of someone grabbing your arm as you try to leave a room and refusing to let go until you answer a survey.
Browsers eventually (and rightfully) nuked this capability.
Why sendBeacon Isn't the Hero We Wanted
Then came navigator.sendBeacon(). It was supposed to be the savior. It sends an asynchronous POST request that the browser guarantees will be scheduled even if the page is closed.
// The "classic" way
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/api/log-out', JSON.stringify({ session: '123' }));
}
});But sendBeacon has some annoying baggage:
1. POST only: You can't use other methods.
2. Opaque headers: You have very little control over the headers. Want to send a custom Authorization token? Good luck; you usually have to resort to Blob hacks to change the Content-Type.
3. Unreliable implementation: I've seen weird edge cases in older versions of Safari where the beacon just... never fired.
Enter fetch with keepalive
The modern solution—the one that finally made my logs and analytics match—is the keepalive flag in the standard Fetch API.
When you set keepalive: true, you're telling the browser: "Hey, even if this document gets nuked, keep this specific HTTP request alive in the background until it finishes."
Here is what a robust exit-intent tracker looks like today:
const trackExit = async (data) => {
const url = 'https://api.yourdomain.com/v1/analytics/exit';
try {
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token_123' // Yes, custom headers work!
},
body: JSON.stringify(data),
keepalive: true, // The magic sauce
});
} catch (error) {
// This will likely only fire if the URL is invalid
// or there's a network-level failure.
console.error('Telemetric ghosting failed:', error);
}
};The "When" is as Important as the "How"
If you’re still using window.addEventListener('unload', ...) or beforeunload, stop. Modern browsers (especially on mobile) often skip these events entirely to save battery and memory.
The gold standard is visibilitychange. If a user switches tabs, minimizes the browser, or closes the app, visibilityState becomes hidden. That is your moment to strike.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
trackExit({
timeOnPage: performance.now(),
lastAction: 'clicked_cta',
scrollDepth: 0.75
});
}
});The 64KB Ceiling
There is a catch. You can't just dump a 5MB JSON state into a keepalive fetch. Browsers impose a limit on the total size of "inflight" keepalive requests. Usually, this is around 64KB.
If you try to send more, the fetch will throw a TypeError. This is the browser’s way of saying, "I'll help you pack, but I'm not carrying your entire wardrobe." Keep your exit payloads lean. Send IDs, timestamps, and small state objects, not your entire Redux store.
Why this actually solved my problem
The reason my logs finally matched was two-fold:
1. Reliability: keepalive is prioritized by the browser's networking layer even during process termination.
2. Auth: Because I could finally send proper Authorization headers, my server-side middleware stopped rejecting "anonymous" beacon hits that were actually authenticated sessions.
If you’re still scratching your head over missing analytics, ditch the unload listeners and the flaky beacons. Move your logic into a visibilitychange handler, flip the keepalive switch on your fetch, and watch the data actually show up for work.
It’s a small flag, but for your data integrity, it's a massive win.


