Web (JavaScript)
Website sessions via visibilitychange, navigator.sendBeacon with text/plain and the key in the body, and a localStorage queue.
A recipe for websites and web apps in plain JavaScript with no dependencies. A session is the time
the tab is visible to the user. session_end is sent with navigator.sendBeacon: the browser
delivers it even while the tab is being closed.
Where to plug the module in for an SPA or a multi-page site is covered in Where to initialize analytics.
If your product also has mobile apps, add the Web platform to the existing app instead of
creating a separate one: then a person with the same user_id on the website and in the app is
counted once. Embed the Web platform key in the module — see Event platform.
Why text/plain and app_key in the body
sendBeacon can't set headers, so the key goes in the app_key body field instead of
X-App-Key. The body is the same JSON, but sent with Content-Type: text/plain: the browser treats
such a request as "simple" and sends it without a CORS preflight. Ingest accepts text/plain on par
with application/json. If both the header and app_key are present, they must match — otherwise
you get 400 invalid_batch.
The server doesn't accept the key in the query string: request URLs end up in proxy logs.
The analytics.js module
const ENDPOINT = "https://i.analytics.example.com/v1/events";
const WRITE_KEY = "pk_…"; // Web platform key from the Integration tab
const QUEUE_KEY = "am_queue";
const MAX_AGE_MS = 7 * 24 * 3600 * 1000;
const MAX_BATCH = 500;
let session = null; // { id, startedAt }
let userId = null;
function installId() {
let id = localStorage.getItem("am_install_id");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("am_install_id", id);
}
return id;
}
function context() {
return {
platform: "web",
app_version: "1.0.0", // site version: a release number or git sha
device_type: matchMedia("(pointer: coarse)").matches ? "phone" : "desktop",
locale: navigator.language,
environment: location.hostname === "localhost" ? "development" : "production",
};
}
function newEvent(name, extra = {}) {
return {
insert_id: crypto.randomUUID(),
name,
timestamp: new Date().toISOString(),
install_id: installId(),
session_id: session.id,
...(userId ? { user_id: userId } : {}),
context: context(),
...extra,
};
}
// sent_at is the moment of sending: the server uses it to correct a wrong computer clock.
function body(events) {
return JSON.stringify({ app_key: WRITE_KEY, sent_at: new Date().toISOString(), events });
}
function loadQueue() {
try {
return JSON.parse(localStorage.getItem(QUEUE_KEY) ?? "[]");
} catch {
return [];
}
}
function saveQueue(queue) {
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
function enqueue(event) {
saveQueue([...loadQueue(), event]);
}
// Sends the queue via fetch. Only what the server has processed is removed from the queue.
export async function flush() {
const minTime = Date.now() - MAX_AGE_MS;
const queue = loadQueue().filter((e) => Date.parse(e.timestamp) >= minTime);
saveQueue(queue);
if (queue.length === 0 || !navigator.onLine) return;
const batch = queue.slice(0, MAX_BATCH);
let done;
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: body(batch),
});
if (res.ok) {
const result = await res.json();
const retry = new Set(result.rejected.filter((r) => r.retryable).map((r) => r.insert_id));
done = batch.filter((e) => !retry.has(e.insert_id));
} else if ([400, 413, 415].includes(res.status)) {
done = batch; // retrying won't help: the bug is in the client code
} else {
return; // 401, 403, 429, 5xx: keep in the queue until the next attempt
}
} catch {
return; // network unavailable
}
const sent = new Set(done.map((e) => e.insert_id));
saveQueue(loadQueue().filter((e) => !sent.has(e.insert_id)));
}
function sessionStart() {
if (session) return;
session = { id: crypto.randomUUID(), startedAt: performance.now() };
enqueue(newEvent("session_start"));
flush();
}
function sessionEnd() {
if (!session) return;
const event = newEvent("session_end", {
duration_ms: Math.min(Math.round(performance.now() - session.startedAt), 86_400_000),
});
session = null;
// The browser queues the beacon itself and delivers it after the tab closes.
const queued = navigator.sendBeacon(ENDPOINT, new Blob([body([event])], { type: "text/plain" }));
if (!queued) enqueue(event); // beacon refused (64 KB limit): it goes out on the next visit
}
export function setUserId(id) {
userId = id; // account ID from your backend, null after sign-out; not an email
}
export function start() {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") sessionStart();
else sessionEnd();
});
window.addEventListener("pagehide", sessionEnd);
window.addEventListener("online", flush);
if (document.visibilityState === "visible") sessionStart();
}The page can't see the beacon's response, so a rejected session_end can't be retried. That's
acceptable: a lost session_end doesn't lose the session, it's only left out of the average duration
(see Sessions).
Setup
<script type="module">
import { start, setUserId } from "/analytics.js";
start();
// After sign-in: setUserId(account.id); after sign-out: setUserId(null).
</script>In an SPA, navigating between pages doesn't create new sessions: a session lasts as long as the tab is visible.
Web caveats
- Each tab is its own session. Two open tabs produce two sessions for the same person: active users stay the same, but the session count grows.
install_idlives inlocalStorage. Clearing site data, incognito mode, or a different browser means a new install. Signing in with auser_idstitches such installs into one person.- Ad blockers may block analytics requests. If that matters, proxy ingest through your own
domain — for example,
/a/v1/eventstohttps://i.analytics.example.com/v1/events. - Country and city are determined from the browser's IP; the IP itself isn't stored.
Verification
Open the site on localhost (events go out with environment: "development"), switch to another
tab, and come back. session_start and session_end appear on the Integration tab. In
DevTools, the Network tab shows the beacon as a request of type ping.