Currently working on a Saas that reads a user’s behavior on a site. The goal is keeping it lightweight and privacy centered.
All interactions are recorded in the browser. When the browser hides the page, a compact summary is calculated and sent to a cloudflare worker for insertion into a D1 DB.
The exit events are strictly bound using this function.
// Bind exit triggers strictly once
if (this.config.endpoint && !this._hasSetupTriggers) {
const handleExit = (event) => {
if (this._hasSent) return;
if (document.visibilityState === 'hidden' || event.type === 'pagehide' || event.type === 'beforeunload') {
this._hasSent = true;
this.forceSend();
}
};
document.addEventListener('visibilitychange', handleExit);
window.addEventListener('pagehide', handleExit);
window.addEventListener('beforeunload', handleExit);
this._hasSetupTriggers = true;
}
2 attempts are made to send.
1st
const success = navigator.sendBeacon(this.config.endpoint, payload);
And 2nd fallback
if (!success) {
fetch(this.config.endpoint, { method: 'POST', body: payload, keepalive: true }).catch(e => console.error(e));
}
But what about if the user returns to the tab?
The function keeps recording when they return. But it won't fire again because of the way I set up handleExit.
const handleExit = (event) => {
if (this._hasSent) return; // <-- THE LOCK
if (document.visibilityState === 'hidden' || event.type === 'pagehide' || event.type === 'beforeunload') {
this._hasSent = true; // <-- LOCKING IT
this.forceSend();
}
};
Because it locks hasSent to true I can't fire another beacon for the session.
I need to turn this._hasSent = false when they return so I can fire again.
const handleVisibilityChange = (event) => {
// 1. User hides the tab, navigates away, or closes the browser
if (document.visibilityState === 'hidden' || event.type === 'pagehide' || event.type === 'beforeunload') {
if (this._hasSent) return; // Prevent duplicate immediate fires
this._hasSent = true; // Lock the beacon
this.forceSend(); // Fire navigator.sendBeacon
}
// 2. User returns to the tab
else if (document.visibilityState === 'visible') {
this._hasSent = false; // Unlock the beacon so it can fire again later
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('pagehide', handleVisibilityChange);
window.addEventListener('beforeunload', handleVisibilityChange);
But this creates another problem, multiple writes for the same session ID (primary key).
I use write or replace for duplicate session IDs. This takes the latest data and scrubs the old.
// --- 7. DATA INGESTION (shape_db) ---
// Force the Project ID to be the securely retrieved domain_whitelist.
const permanentProjectId = authData.domain_whitelist || 'unknown_domain';
// UPDATED: INSERT OR REPLACE handles tab-switching updates for the same sessionId
await env.DB.prepare(`
INSERT OR REPLACE INTO sessions (
id, project_id, schema_version, url, timestamp,
session_duration_ms, event_count, engine_version, tune_version, device, metrics, shapes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
data.sessionId,
permanentProjectId,
data.payloadSchema,
data.url || '',
data.timestamp || new Date().toISOString(),
data.sessionDurationMs,
data.eventCount || 0,
data.engineVersion || '',
data.tuneVersion || '',
data.device || 'unknown',
JSON.stringify(data.metrics, null, 2),
JSON.stringify(data.shapes, null, 2)
).run();
Full project here:
- Repository: View the code on GitHub
- Live Site: Check out the SaaS
Hopefully my experience with users and their desire to open tabs and jump around like jack rabbits help.
Cheers
Top comments (0)