Analytics for PWAs¶
Analytics for a Progressive Web App means measuring the things that make it a PWA: installs, launches from the home screen, sessions in standalone windows, visits that happen offline, notifications that are delivered and tapped, and pages served by the service worker. Default analytics snippets measure none of these, and they lose data when the device is offline or the page is killed in the background. This page builds a complete, privacy-conscious instrumentation layer: context dimensions, an install funnel, a reliable transport built on sendBeacon(), fetch() with keepalive and fetchLater(), an IndexedDB queue that replays offline events, push notification metrics collected in the service worker, service worker performance data, and a collection endpoint.
Key takeaways
- Attach three dimensions to every event: display mode (
browser,standalone, …), launch source (from astart_urlmarker such as?source=pwa), and service worker status (controlled,supported,unsupported). Most PWA questions are comparisons across these. - Installs are only observable on Chromium (
beforeinstallprompt,prompt()outcome,appinstalled). On Safari and Firefox, count launches in app mode instead. No browser tells you about uninstalls. - Send data with
fetch()while the page is alive, and withsendBeacon()orfetch(…, { keepalive: true })whenvisibilitychangereportshidden. Both cap bodies at 64 KiB.fetchLater()(Chromium 135+) sends even if the page is discarded. Don't useunload. - Offline, write events to IndexedDB with a client timestamp and a unique ID, replay them on reconnect (and with Background Sync where it exists), and deduplicate on the server by ID.
- Push metrics live in the service worker: count
push(delivered),notificationclick(opened, withaction) andnotificationclose(dismissed), and put a notification ID in the URL you open. Safari's Declarative Web Push can show and open notifications without running your worker. - Collect first-party, minimize data, key identifiers to sessions unless the user consents, and honor Global Privacy Control where browsers send it.
What default analytics miss in a PWA¶
A standard page-view tag assumes that each page load is a visit in a browser tab with a network connection. A PWA breaks all three assumptions.
| Question | Why a default tag can't answer it | Signal to collect |
|---|---|---|
| How many people installed the app? | No install event is sent; Safari and Firefox have none at all | appinstalled (Chromium), app-mode launches (all) |
| Do installed users behave differently? | Display mode isn't a standard dimension | display-mode media query, navigator.standalone |
| Where did this session start: icon, shortcut, share sheet, notification? | All look like direct traffic | Markers in start_url, shortcut URLs, share_target.action, notification URLs |
| How much do people use the app offline? | Hits sent offline are dropped | Queue in IndexedDB, replay later |
| Are notifications delivered and opened? | Happens in the service worker, where page tags don't run | push, notificationclick, notificationclose |
| Does the service worker make pages faster? | No cohort for "controlled by a service worker" | navigator.serviceWorker.controller, Navigation Timing workerStart |
| Did the user leave, or was the tab frozen and discarded? | unload-based sending is unreliable and bfcache-hostile | visibilitychange, pagehide, fetchLater() |
The rest of this page builds the pieces in that right-hand column. The modules are vendor-neutral: they send to a first-party endpoint, which can store events itself or forward them to an analytics product (Forwarding to Google Analytics 4 shows one).
Architecture of a PWA analytics pipeline¶
flowchart LR
subgraph Page
T["track()"] --> B["In-memory buffer"]
B -->|"timer, visible"| F["fetch POST"]
B -->|"hidden"| S["sendBeacon / keepalive"]
B -->|"Chromium 135+"| L["fetchLater (deferred)"]
F -->|"fails or offline"| Q[("IndexedDB queue")]
end
subgraph SW["Service worker"]
P["push / notificationclick / notificationclose"] --> Q
Y["sync event"] --> R["Replay"]
end
Q --> R
R --> E["/analytics/collect"]
F --> E
S --> E
L --> E
E --> D[("Event store")] Every event has the same shape:
{
"id": "0b8a5c1e-6c1f-4f0e-9a4e-2f6f7f0c9a11",
"name": "page_view",
"ts": 1790000000000,
"session": "b3c1…",
"props": { "path": "/inbox" },
"ctx": {
"displayMode": "standalone",
"launchSource": "homescreen",
"swStatus": "controlled",
"swVersion": "2026.09.20-1",
"online": true,
"appVersion": "4.12.0"
}
}
idis generated on the client withcrypto.randomUUID()so the server can drop duplicates. Replay, beacons and retries all make duplicates possible; deduplication makes them harmless.tsis the client time when the event happened. An event replayed three hours later must be attributed to when it happened, not when it arrived.sessionis a random ID scoped to the window's session. Whether it lives longer depends on consent (see Privacy-friendly analytics).
Context dimensions every event needs¶
Display mode¶
The display-mode media feature reports how the document is presented. MDN lists six values: browser, fullscreen, minimal-ui, standalone, window-controls-overlay and picture-in-picture. Engines differ in what they report for installed apps, which Detecting Installed Apps covers in detail. The two facts that affect analytics:
- iOS and iPadOS Home Screen apps (and, since Safari 17, macOS Dock web apps) expose
navigator.standalone === true, and that is more reliable on Apple platforms than the media query, so check it first: an iOS web app whose manifest saysstandalonematches(display-mode: fullscreen), and one with no manifest reportsbrowser. fullscreenalso matches when a page in a browser tab calls the Fullscreen API (a video player, a game). Record it, but don't count it as "installed" on its own.
const DISPLAY_MODES = [
"picture-in-picture",
"window-controls-overlay",
"fullscreen",
"standalone",
"minimal-ui",
];
/** @returns {{ displayMode: string, appMode: boolean }} */
export function getDisplayContext() {
// Safari: iOS/iPadOS Home Screen web apps and macOS Dock web apps set navigator.standalone.
if (navigator.standalone === true) {
return { displayMode: "standalone", appMode: true };
}
for (const mode of DISPLAY_MODES) {
if (matchMedia(`(display-mode: ${mode})`).matches) {
// fullscreen can come from the Fullscreen API in a normal tab.
const appMode = mode !== "fullscreen" || !document.fullscreenElement;
return { displayMode: mode, appMode };
}
}
return { displayMode: "browser", appMode: false };
}
/** Calls back when the display mode changes (for example, after an install
* opens the app window, or when window controls overlay is toggled). */
export function onDisplayModeChange(callback) {
const queries = DISPLAY_MODES.map((m) => matchMedia(`(display-mode: ${m})`));
const handler = () => callback(getDisplayContext());
for (const q of queries) q.addEventListener("change", handler);
return () => queries.forEach((q) => q.removeEventListener("change", handler));
}
Send the display mode with every event, not once per session. A user can move a tab into an app window, and Chromium on desktop can open links from the app in a browser tab.
Launch source from start_url and other entry points¶
Display mode says where the page runs. It doesn't say how the session began. Put a marker in each entry point's URL:
{
"id": "/",
"start_url": "/?source=homescreen",
"shortcuts": [
{ "name": "Compose", "url": "/compose?source=shortcut-compose" },
{ "name": "Inbox", "url": "/inbox?source=shortcut-inbox" }
],
"share_target": {
"action": "/share?source=share-target",
"method": "GET",
"params": { "title": "title", "text": "text", "url": "url" }
},
"protocol_handlers": [
{ "protocol": "web+acme", "url": "/open?source=protocol&target=%s" }
]
}
The explicit id matters: without it, the app's identity is derived from start_url, so adding a marker to an existing app's start_url creates a different app for everyone who already installed it (App Identity & Updates). For file handlers, the launch goes through launchQueue rather than a URL marker; record a file_handler source in the consumer (see File Handling).
Read the marker once, remember it for the lifetime of the window, and remove it from the address bar so it isn't shared or indexed (SEO for PWAs covers the canonical URL):
const PARAM = "source";
const KEY = "analytics.launchSource";
function safeSession(fn, fallback = null) {
try {
return fn(sessionStorage);
} catch {
return fallback; // storage blocked (private mode, sandboxed iframe, policy)
}
}
/** Determine how this window's session started. Call once, early. */
export function captureLaunchSource() {
const url = new URL(location.href);
let source = url.searchParams.get(PARAM);
// Trusted Web Activity: the first navigation has an android-app:// referrer.
if (!source && document.referrer.startsWith("android-app://")) {
source = `twa:${document.referrer.slice("android-app://".length).split("/")[0]}`;
}
if (source) {
safeSession((s) => s.setItem(KEY, source));
url.searchParams.delete(PARAM);
history.replaceState(history.state, "", url); // keep router state intact
}
return getLaunchSource();
}
export function getLaunchSource() {
return safeSession((s) => s.getItem(KEY)) ?? (document.referrer ? "referral" : "direct");
}
sessionStorage is per window, which is exactly the scope you want: each app window and each tab has its own launch source. On iOS the installed app has storage separate from Safari, so sessions in the two can't be joined on the client. On Chromium, installed apps and browser tabs share one cookie jar, so a cookie is the wrong place for per-launch state.
Service worker status and version¶
The status dimension from Google's I/O web app analysis (Measuring the real-world performance impact of service workers) splits every metric into three cohorts:
controlled: a service worker controls this page. Usually a repeat visit with warm caches.supported: the browser supports service workers but none controls the page yet. Usually a first visit.unsupported: no service worker support.
Add the worker's version so you can compare releases and spot clients stuck on an old worker (Updating Service Workers):
export function getServiceWorkerStatus() {
if (!("serviceWorker" in navigator)) return "unsupported";
return navigator.serviceWorker.controller ? "controlled" : "supported";
}
/** Ask the controlling worker for its version; resolves null after a timeout
* so analytics never waits on a slow or old worker. */
export function getServiceWorkerVersion(timeoutMs = 1000) {
const controller = navigator.serviceWorker?.controller;
if (!controller) return Promise.resolve(null);
return new Promise((resolve) => {
const { port1, port2 } = new MessageChannel();
const timer = setTimeout(() => {
port1.close();
resolve(null);
}, timeoutMs);
port1.onmessage = (event) => {
clearTimeout(timer);
port1.close();
resolve(event.data?.version ?? null);
};
controller.postMessage({ type: "GET_VERSION" }, [port2]);
});
}
const SW_VERSION = "2026.09.20-1";
self.addEventListener("message", (event) => {
if (event.data?.type === "GET_VERSION" && event.ports[0]) {
event.ports[0].postMessage({ version: SW_VERSION });
}
});
The messaging pattern is covered in Messaging & the Clients API.
The client module: buffering, transport and page lifecycle¶
Choosing a transport¶
| Mechanism | Body limit | Response readable | Survives page unload | Available in service workers | Support |
|---|---|---|---|---|---|
fetch() | None beyond the server's | Yes | No | Yes | All |
fetch(url, { keepalive: true }) | 64 KiB total for in-flight keepalive requests | Yes, if the page is still alive | Yes | Yes | Chromium 66+, Safari 13+, Firefox 133+ |
navigator.sendBeacon(url, data) | 64 KiB | No (returns true if queued) | Yes | No (page Navigator only) | All current engines |
fetchLater(url, init) | 64 KiB per reporting origin at a time, within a per-document quota | No | Yes, and also if the page is discarded or crashes | No | Chromium 135+ |
Support data as of September 2026. See MDN for sendBeacon(), RequestInit.keepalive and fetchLater() for live data.
Details that change how you write the code:
sendBeacon()always sends aPOST. Its return value only says whether the browser queued the data.falsemeans the payload was too large or the queue was full, which is your cue to persist the events instead.- A
keepaliverequest fails if the total size of all in-flight keepalive bodies from the page would exceed 64 KiB. Keep beacons small and split large batches. fetchLater()requires a potentially trustworthy (HTTPS) URL and a body of known length (no streams), throwsQuotaExceededErrorwhen the quota is exhausted, and discards the response. The optionalactivateAftersends the request after a delay even if the page stays open. You update a pending request by aborting it with anAbortControllerand creating a new one. MDN's Using Deferred Fetch documents the quotas: 640 KiB per top-level document, split into 512 KiB for the top-level document and its same-origin subframes and 128 KiB shared by cross-origin subframes (8 KiB each by default, or 64 KiB for origins granted thedeferred-fetchPermissions Policy). Within the 512 KiB, one reporting origin can use at most 64 KiB concurrently, which is why the module below keeps one pending request and replaces it.- Send to a same-origin endpoint. A cross-origin beacon with a JSON content type is a CORS request that needs a preflight and credentials handling, and it's more likely to be blocked by content blockers.
When to send: visibilitychange, pagehide and not unload¶
Mobile browsers routinely freeze and discard background pages without firing unload, and unload handlers make pages ineligible for the back/forward cache. Chrome is removing unload altogether: its deprecation plan rolls the change out gradually, from 1% of page loads in Chrome 146 (March 2026) to 100% in Chrome 154 (dated September 22, 2026, and marked as subject to change). Because the rollout is based on page loads rather than on users or sites, you can't predict whether unload fires for a given visit. Use:
visibilitychangewithdocument.visibilityState === "hidden": the last event you can rely on for most endings (tab switch, app switch, home screen, navigation, close). Treat eachhiddenas a possible end of the session and flush.pagehide: covers navigations and closes, and is bfcache-friendly. Engines have differed in which of the two events fires in which ending, so listen to both; the flush function is idempotent because it empties the buffer.pageshowwithevent.persisted === true: the page came back from the bfcache. Count it as a new page view; no newloadfires.
The client module¶
The module below buffers events, sends them in batches, flushes on hidden, uses fetchLater() where available as a crash-safe backup, and falls back to the IndexedDB queue (next section) whenever sending fails or the device is offline.
import "./queue.js"; // defines globalThis.AnalyticsQueue (shared with the SW)
import { getDisplayContext, onDisplayModeChange } from "./context.js";
import { captureLaunchSource } from "./launch.js";
import { getServiceWorkerStatus, getServiceWorkerVersion } from "./sw-context.js";
import { getSessionId } from "./identity.js";
const ENDPOINT = "/analytics/collect";
const BATCH_SIZE = 20;
const FLUSH_DELAY_MS = 5000;
const MAX_BEACON_BYTES = 60 * 1024; // below the 64 KiB beacon/keepalive limit
const queue = globalThis.AnalyticsQueue;
const replay = () => queue.flush(postBatch).catch(() => {}); // retried on next trigger
let buffer = [];
let flushTimer = 0;
let deferred = null; // { controller, result } for fetchLater
const ctx = {
...getDisplayContext(),
launchSource: captureLaunchSource(),
swStatus: getServiceWorkerStatus(),
swVersion: null,
appVersion: document.documentElement.dataset.appVersion ?? "unknown",
};
getServiceWorkerVersion().then((v) => (ctx.swVersion = v));
onDisplayModeChange((display) => {
Object.assign(ctx, display);
track("display_mode_change", { to: display.displayMode });
});
export function track(name, props = {}) {
const event = {
id: crypto.randomUUID(),
name,
ts: Date.now(),
session: getSessionId(),
props,
ctx: { ...ctx, online: navigator.onLine },
};
buffer.push(event);
armDeferredSend();
if (buffer.length >= BATCH_SIZE) flush();
else if (!flushTimer) flushTimer = setTimeout(flush, FLUSH_DELAY_MS);
return event.id;
}
/** Normal path while the page is alive: fetch, falling back to IndexedDB. */
async function flush() {
clearTimeout(flushTimer);
flushTimer = 0;
const events = takeBuffer();
if (!events.length) return;
if (!navigator.onLine || !(await postBatch(events))) {
await persist(events);
}
}
/** Sends a batch; resolves true if the server accepted or permanently
* rejected it (so a malformed batch doesn't loop forever). */
export async function postBatch(events) {
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sentAt: Date.now(), events }),
credentials: "same-origin",
});
if (res.ok) return true;
// 4xx other than timeout/rate limit: retrying won't help.
return res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429;
} catch {
return false; // network error: offline, captive portal, DNS failure
}
}
/** End-of-life path: the page is being hidden and may never run again. */
function flushOnHide() {
clearTimeout(flushTimer);
flushTimer = 0;
const events = takeBuffer();
if (!events.length) return;
if (!navigator.onLine) {
persist(events); // best effort: IndexedDB writes usually complete
return;
}
for (const chunk of chunkBySize(events, MAX_BEACON_BYTES)) {
const body = JSON.stringify({ sentAt: Date.now(), events: chunk });
const blob = new Blob([body], { type: "application/json" });
let queued = false;
try {
queued = navigator.sendBeacon?.(ENDPOINT, blob) ?? false;
} catch {
queued = false;
}
if (!queued) {
try {
fetch(ENDPOINT, { method: "POST", body: blob, keepalive: true, headers: { "Content-Type": "application/json" } })
.catch(() => persist(chunk));
} catch {
persist(chunk);
}
}
}
}
/** fetchLater keeps an up-to-date copy of the buffer queued in the browser.
* If the page is discarded or crashes, the browser still sends it. */
function armDeferredSend() {
if (typeof globalThis.fetchLater !== "function") return;
dropDeferredSent();
cancelDeferredSend();
if (!buffer.length) return;
const body = JSON.stringify({ sentAt: Date.now(), deferred: true, events: buffer });
if (body.length > MAX_BEACON_BYTES) return; // normal flush will handle it
const controller = new AbortController();
try {
const result = fetchLater(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: controller.signal,
activateAfter: 60_000, // send after a minute even if the page lives on
});
deferred = { controller, result, ids: new Set(buffer.map((e) => e.id)) };
} catch {
deferred = null; // QuotaExceededError or policy: rely on the other paths
}
}
function cancelDeferredSend() {
if (deferred && !deferred.result.activated) deferred.controller.abort();
deferred = null;
}
/** If the pending fetchLater already fired, its events were sent: drop them. */
function dropDeferredSent() {
if (deferred?.result.activated) {
const sent = deferred.ids;
buffer = buffer.filter((e) => !sent.has(e.id));
deferred = null;
}
}
function takeBuffer() {
dropDeferredSent();
cancelDeferredSend();
const events = buffer;
buffer = [];
return events;
}
async function persist(events) {
try {
await queue.enqueue(events);
// Ask the service worker to replay when connectivity returns (Chromium).
const reg = await navigator.serviceWorker?.ready;
await reg?.sync?.register("analytics-replay");
} catch {
// Storage unavailable or quota exceeded: the events are lost. Don't throw
// from analytics code.
}
}
function* chunkBySize(events, maxBytes) {
let chunk = [];
let size = 64; // envelope overhead
for (const e of events) {
const bytes = JSON.stringify(e).length + 1;
if (chunk.length && size + bytes > maxBytes) {
yield chunk;
chunk = [];
size = 64;
}
chunk.push(e);
size += bytes;
}
if (chunk.length) yield chunk;
}
// Lifecycle wiring.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushOnHide();
else replay(); // back in the foreground: replay stored events
});
addEventListener("pagehide", flushOnHide);
addEventListener("pageshow", (event) => {
if (event.persisted) track("page_view", { path: location.pathname, bfcache: true });
});
addEventListener("online", replay);
// Replay anything left from earlier offline sessions.
replay();
Three details are easy to miss:
fetchLater()and duplicates. IfactivateAfterfires while the page is open,result.activatedbecomestrueand those events are already on their way.dropDeferredSent()removes exactly those events (tracked by ID) from the buffer instead of sending them twice. There is a tiny race between the check and the abort; server-side deduplication byidcovers it.- String length versus bytes.
JSON.stringify(e).lengthcounts UTF-16 code units, not bytes. With non-ASCII content a chunk can exceed its budget. The 4 KiB margin under 64 KiB absorbs typical cases; usenew TextEncoder().encode(...)if your events carry long non-Latin text. - Prerendering. If a page is prerendered with the Speculation Rules API (Chromium), it runs before the user sees it. Defer the initial
page_viewuntildocument.prerenderingisfalse(listen forprerenderingchange), and measure timings relative toactivationStart.
import { track } from "./index.js";
function sendPageView() {
track("page_view", { path: location.pathname, title: document.title });
}
if (document.prerendering) {
document.addEventListener("prerenderingchange", sendPageView, { once: true });
} else {
sendPageView();
}
Offline analytics: queue in IndexedDB and replay¶
The shared queue¶
The queue lives in IndexedDB because it's the only durable, asynchronous storage available both to pages and to the service worker (IndexedDB). It's written as a classic script so the page can import it as a module side effect and the service worker can load it with importScripts().
/* Shared by pages (import "./queue.js") and the service worker
(importScripts("/analytics/queue.js")). Defines globalThis.AnalyticsQueue. */
(() => {
const DB_NAME = "analytics";
const DB_VERSION = 1;
const STORE = "events";
const MAX_EVENTS = 5000; // an offline device must not fill the origin's quota
const MAX_AGE_MS = 72 * 60 * 60 * 1000; // match what your backend accepts
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: "id" });
store.createIndex("ts", "ts");
}
};
req.onsuccess = () => {
const db = req.result;
// A newer version was opened elsewhere: close so its upgrade can run.
db.onversionchange = () => {
db.close();
dbPromise = null;
};
resolve(db);
};
req.onerror = () => {
dbPromise = null;
reject(req.error);
};
});
return dbPromise;
}
function done(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new DOMException("Aborted", "AbortError"));
});
}
async function enqueue(events) {
const db = await openDB();
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
for (const event of events) store.put(event); // put: idempotent by id
await done(tx);
await trim();
}
/** Oldest events first; drops expired events while scanning. */
async function peek(limit) {
const db = await openDB();
const cutoff = Date.now() - MAX_AGE_MS;
const tx = db.transaction(STORE, "readwrite");
const out = [];
const req = tx.objectStore(STORE).index("ts").openCursor();
req.onsuccess = () => {
const cursor = req.result;
if (!cursor || out.length >= limit) return;
if (cursor.value.ts < cutoff) cursor.delete();
else out.push(cursor.value);
cursor.continue();
};
await done(tx);
return out;
}
async function remove(ids) {
const db = await openDB();
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
for (const id of ids) store.delete(id);
await done(tx);
}
async function count() {
const db = await openDB();
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).count();
await done(tx);
return req.result;
}
/** Keep at most MAX_EVENTS, deleting the oldest. */
async function trim() {
const excess = (await count()) - MAX_EVENTS;
if (excess <= 0) return;
const db = await openDB();
const tx = db.transaction(STORE, "readwrite");
let deleted = 0;
const req = tx.objectStore(STORE).index("ts").openCursor();
req.onsuccess = () => {
const cursor = req.result;
if (!cursor || deleted >= excess) return;
cursor.delete();
deleted++;
cursor.continue();
};
await done(tx);
}
/**
* Replay stored events. `send(batch)` resolves true when the batch was
* accepted. Stops at the first failure so order is preserved.
* A Web Lock stops tabs and the service worker from replaying concurrently.
*/
async function flush(send, batchSize = 100) {
const run = async () => {
let sent = 0;
for (;;) {
const batch = await peek(batchSize);
if (!batch.length) return sent;
if (!(await send(batch))) {
throw new Error(`Replay stopped after ${sent} events`);
}
await remove(batch.map((e) => e.id));
sent += batch.length;
}
};
const locks = globalThis.navigator?.locks;
if (!locks) return run();
return locks.request("analytics-replay", { ifAvailable: true }, (lock) =>
lock ? run() : 0 // someone else is replaying
);
}
globalThis.AnalyticsQueue = { enqueue, peek, remove, count, flush, MAX_AGE_MS };
})();
flush() rejects when a batch fails, so callers decide what failure means: the client module's replay() swallows the error (the next trigger retries), while the service worker's sync handler lets it reject so the browser schedules a retry.
Why these limits:
- 72 hours. An event that arrives days late distorts daily reports and may be rejected anyway. Google Analytics 4's Measurement Protocol, for example, accepts timestamps up to 72 hours in the past (sending events). Pick the window your backend accepts.
- 5,000 events. A device offline for days generates events indefinitely. Cap the queue so analytics can't consume the storage your app needs (Storage Quotas & Persistence).
putkeyed byid. Writing the same event twice (for example, a failed beacon persisted after a partially successful send) doesn't duplicate it.
Replaying from the service worker¶
Pages replay on load, on online and when they become visible. The service worker adds two triggers: the Background Sync sync event, which Chromium fires when connectivity returns even if no page is open (Background Sync), and worker activation.
importScripts("/analytics/queue.js");
const ENDPOINT = "/analytics/collect";
async function sendBatch(events) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sentAt: Date.now(), replay: true, events }),
});
if (res.ok) return true;
return res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429;
}
self.addEventListener("sync", (event) => {
if (event.tag === "analytics-replay") {
// Rejecting tells the browser to retry later with backoff.
event.waitUntil(self.AnalyticsQueue.flush(sendBatch));
}
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.AnalyticsQueue.flush(sendBatch).catch(() => {}));
});
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
import { BackgroundSyncPlugin } from "workbox-background-sync";
// Failed POSTs to the collector are stored by Workbox in its own IndexedDB
// queue and replayed on `sync` (Chromium) or on the next worker startup.
const analyticsQueue = new BackgroundSyncPlugin("analytics", {
maxRetentionTime: 72 * 60, // minutes
});
registerRoute(
({ url, request }) => url.pathname === "/analytics/collect" && request.method === "POST",
new NetworkOnly({ plugins: [analyticsQueue] }),
"POST"
);
The Workbox variant intercepts the page's own failed fetch() calls, so the page doesn't need its own queue. It can't capture sendBeacon() requests reliably after the page is gone, and it doesn't run in browsers without a controlling worker, so keep the page-level queue for those cases. See Advanced Workbox.
workbox-google-analytics doesn't support GA4
The workbox-google-analytics module, which replayed Universal Analytics hits offline, is deprecated because it "is not compatible with newer Google Analytics versions starting with version 4" (module docs). For GA4, queue your own events and forward them server-side, as shown below.
Server-side handling of replayed events¶
A replayed event carries the client timestamp from when it happened, taken from a clock you don't control. The collector should:
- Deduplicate by
id(a unique index, or an upsert). - Correct clock skew using the batch envelope:
sentAtis the client's clock at send time; the server's receive time minussentAtestimates the offset, which you add to each event'sts. - Reject or clamp events older than your accept window.
- Record
replayed: trueso you can report how much of your traffic happens offline.
Measuring installs¶
The install funnel on Chromium¶
Only Chromium-based browsers expose installation events: beforeinstallprompt (the site is installable and the browser would allow a prompt), the prompt() result, and appinstalled. The complete funnel design and install UI live in Install Prompts & Custom UI. This module instruments it:
import { track } from "./index.js";
let deferredPrompt = null;
let lastPromptSource = null;
const PROMOTABLE_KEY = "analytics.installPromotable";
addEventListener("beforeinstallprompt", (event) => {
event.preventDefault(); // suppress the mini-infobar; show your own UI
deferredPrompt = event;
// Fires on every eligible page load: report once per session.
try {
if (!sessionStorage.getItem(PROMOTABLE_KEY)) {
sessionStorage.setItem(PROMOTABLE_KEY, "1");
track("install_promotable", { platforms: event.platforms ?? [] });
}
} catch {
track("install_promotable", {});
}
document.dispatchEvent(new CustomEvent("installavailable"));
});
/** Call from a click handler (user activation is required). */
export async function promptInstall(source) {
if (!deferredPrompt) return "unavailable";
const promptEvent = deferredPrompt;
deferredPrompt = null; // an event can be prompted only once
lastPromptSource = source;
track("install_prompt_shown", { source });
try {
const { outcome, platform } = await promptEvent.prompt();
track("install_prompt_result", { source, outcome, platform });
return outcome; // "accepted" | "dismissed"
} catch (error) {
track("install_prompt_error", { source, error: error.name });
return "error";
}
}
addEventListener("appinstalled", () => {
// Also fires for installs from the browser's own UI (address bar, menu).
track("app_installed", { via: lastPromptSource ?? "browser_ui" });
lastPromptSource = null;
});
Launches: the install metric that works everywhere¶
Safari (iOS, iPadOS, macOS) and Firefox fire no install events. What you can observe everywhere is the result of an install: sessions whose display mode is an app mode, started from your start_url marker. Report a app_launch event at startup when appMode is true, with the launch source. Useful derived metrics:
| Metric | Definition | Notes |
|---|---|---|
| Prompt acceptance rate | install_prompt_result with accepted ÷ install_prompt_shown | Chromium only |
| Install rate | app_installed ÷ sessions where install_promotable fired | Chromium only; includes browser-UI installs |
| Installed active users | Distinct users (or sessions) with app_launch in a period | All engines; needs a user ID to count people rather than sessions |
| Launches per installed user | app_launch count ÷ installed active users | Engagement of installed users |
| Launch mix | app_launch by launchSource | Icon vs shortcut vs share target vs notification |
| App-mode share | Events with appMode: true ÷ all events | Adoption of the installed experience |
Uninstalls are unobservable. No browser fires an event when an app is removed. Proxies: installed users whose app_launch events stop, and push subscriptions that start returning 404 or 410 Gone from the push service. Clearing site data or revoking notification permission removes a subscription; whether uninstalling does depends on the platform (on iOS and iPadOS, push belongs to the Home Screen app, so removing the app ends it), so treat expired subscriptions as a lower bound on churn, not an uninstall count. Chromium's navigator.getInstalledRelatedApps() can tell a browser tab that your PWA or native app is installed; Detecting Installed Apps covers its limits.
Push notification metrics¶
The funnel¶
sequenceDiagram
participant Page
participant Server as App server
participant PS as Push service
participant SW as Service worker
participant OS as OS notification UI
Page->>Page: permission prompt (track result)
Page->>Server: subscription (track subscribed)
Server->>PS: POST message (log id, status 201)
PS->>SW: push event (track push_received)
SW->>OS: showNotification (track notification_shown)
OS-->>SW: notificationclick (track click, action)
OS-->>SW: notificationclose (track dismissed)
SW->>Page: openWindow(url?nid=id) (track landing) Each step is measured in a different place:
| Stage | Measured in | Signal |
|---|---|---|
| Permission asked / granted / denied | Page | Notification.requestPermission() result; navigator.permissions.query({ name: "notifications" }) and its change event for later changes in browser settings |
| Subscribed / unsubscribed | Page and server | pushManager.subscribe() success; subscription stored on the server |
| Sent / accepted | Server | Push service response: 201 Created means accepted for delivery |
| Expired subscription | Server | 404 or 410 from the push service: delete the subscription and count it |
| Delivered | Service worker | push event |
| Shown | Service worker | showNotification() resolved |
| Opened | Service worker | notificationclick, with event.action for action buttons |
| Dismissed | Service worker | notificationclose (not in Safari on iOS and iPadOS) |
| Converted | Page | Events in the session opened from the notification |
The protocol responses are described in The Web Push Protocol, and the notification APIs in Notifications API.
Instrumenting the service worker¶
The service worker can't use sendBeacon() (it's only on the page's Navigator). It doesn't need to: event.waitUntil() keeps the worker alive until the promise settles. Record every event to the queue first, then try to send, so a killed worker or an offline device loses nothing.
// Loaded with importScripts() after sw.js defines SW_VERSION.
importScripts("/analytics/queue.js");
const COLLECT = "/analytics/collect";
async function recordSW(name, props) {
const event = {
id: crypto.randomUUID(),
name,
ts: Date.now(),
session: null, // no page session in the worker
props,
ctx: { source: "service-worker", swVersion: SW_VERSION, online: navigator.onLine },
};
await self.AnalyticsQueue.enqueue([event]);
// Try to send right away; if it fails, a later flush or sync replays it.
try {
await self.AnalyticsQueue.flush(async (batch) => {
const res = await fetch(COLLECT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sentAt: Date.now(), events: batch }),
});
return res.ok;
});
} catch {
if (self.registration.sync) {
await self.registration.sync.register("analytics-replay").catch(() => {});
}
}
}
self.addEventListener("push", (event) => {
// Payload format is yours: { id, title, body, url, campaign }.
let data = {};
try {
data = event.data?.json() ?? {};
} catch {
data = { title: event.data?.text() ?? "Update" };
}
const nid = data.id ?? "unknown";
event.waitUntil(
(async () => {
// Don't make the user wait for an analytics round trip: start recording,
// show the notification, then wait for both inside waitUntil().
const received = recordSW("push_received", { nid, campaign: data.campaign }).catch(() => {});
try {
await self.registration.showNotification(data.title ?? "Update", {
body: data.body,
tag: data.tag,
icon: "/icons/icon-192.png",
badge: "/icons/badge-72.png",
data: { nid, url: data.url ?? "/", campaign: data.campaign },
actions: data.actions ?? [],
});
} catch (error) {
// Permission revoked between send and receipt, invalid options, etc.
await recordSW("notification_error", { nid, error: error.name }).catch(() => {});
await received;
return;
}
await Promise.all([
received,
recordSW("notification_shown", { nid, campaign: data.campaign }).catch(() => {}),
]);
})()
);
});
self.addEventListener("notificationclick", (event) => {
const { nid, url, campaign } = event.notification.data ?? {};
event.notification.close();
// Carry the notification id into the page so the session can be attributed.
const target = new URL(url ?? "/", self.location.origin);
target.searchParams.set("source", "notification");
target.searchParams.set("nid", nid ?? "unknown");
event.waitUntil(
Promise.all([
recordSW("notification_click", { nid, campaign, action: event.action || "default" }),
openOrFocus(target.href),
])
);
});
self.addEventListener("notificationclose", (event) => {
const { nid, campaign } = event.notification.data ?? {};
event.waitUntil(recordSW("notification_dismissed", { nid, campaign }));
});
async function openOrFocus(href) {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) {
if (client.url === href && "focus" in client) return client.focus();
}
return self.clients.openWindow(href);
}
The page's captureLaunchSource() reads source=notification, and the page should read and strip nid the same way so the session is attributed to the notification. The opening logic is covered in Push Notifications.
Browser differences that affect these numbers:
notificationcloseisn't available everywhere. MDN's compatibility data lists it in Chrome, Firefox and Safari 16 and later on macOS, but not in Safari on iOS and iPadOS, where most Home Screen web app notifications are shown. Report dismissals as "where supported" and don't compute a dismissal rate across engines.- Delivery isn't guaranteed. A message can expire in the push service (its
TTL), and a device can be offline beyond it.push_received ÷ sentis your delivery rate. Compare it per platform. - Declarative Web Push. Home Screen web apps on iOS and iPadOS 18.4 and later, and Safari 18.5 and later on macOS, support Declarative Web Push: the push payload itself describes the notification (
title,body, a requirednavigateURL), and the browser can display and open it without running your service worker. Yourpushandnotificationclickhandlers may not run for those messages, so count opens with a marker in thenavigateURL (for example?source=notification&nid=…), recorded by the page. See Web Push on iOS & Safari. - Every push must show a notification on Chromium (
userVisibleOnly: true) and Safari. Don't use silent pushes for analytics pings.
Tracking permission changes¶
Users revoke notification permission in browser or OS settings without visiting your page. Record the current state at startup and listen for changes where supported:
import { track } from "./index.js";
export async function watchNotificationPermission() {
if (!("Notification" in globalThis)) return;
const KEY = "analytics.lastNotificationPermission";
const report = (state) => {
let last = null;
try {
last = localStorage.getItem(KEY);
localStorage.setItem(KEY, state);
} catch {}
if (last !== state) track("notification_permission", { state, previous: last });
};
report(Notification.permission); // "default" | "granted" | "denied"
try {
const status = await navigator.permissions.query({ name: "notifications" });
status.addEventListener("change", () => report(Notification.permission));
} catch {
// Permissions API doesn't know "notifications" in this engine.
}
}
Service worker performance metrics¶
A service worker can make navigations faster (cached HTML, streamed responses) or slower (startup time added before every network request). Measure both, per cohort. Measuring Performance covers the general RUM setup; the PWA-specific signals are:
| Signal | Source | Meaning |
|---|---|---|
workerStart | Navigation / Resource Timing | 0 if no service worker handled the request; otherwise the time the worker started handling it (including startup if it wasn't running) |
responseEnd - workerStart | Navigation Timing | Service worker time plus response time for the document |
responseStart | Navigation Timing | Time to first byte from the browser's point of view, including worker time |
deliveryType | Navigation / Resource Timing | "cache" when served from the HTTP cache, "navigational-prefetch" for a prefetched navigation, "" otherwise (Chromium 117+, Safari 26.4+) |
workerMatchedRouterSource, workerFinalRouterSource | Navigation / Resource Timing | Which Static Routing API source matched and what finally served the request. MDN lists the spec names in Safari 27 only; Chromium (140+) exposes the same values as non-standard workerMatchedSourceType and workerFinalSourceType (the Microsoft Edge 149 release notes announce the spec names, but Chromium's IDL still declares only the old ones as of September 2026), so read both |
workerRouterEvaluationStart, workerCacheLookupStart | Navigation / Resource Timing | When router rule evaluation started, and when a cache source started looking up Cache Storage. Chromium 140+ and Safari 27 per MDN |
activationStart | Navigation Timing | Non-zero for prerendered pages; subtract it from other timings |
| Worker-reported source | Your fetch handler | cache, network, preload, offline-fallback, with the strategy name |
import { track } from "./index.js";
export function reportNavigationTiming() {
const [nav] = performance.getEntriesByType("navigation");
if (!nav) return;
const activation = nav.activationStart ?? 0; // prerendered pages
const handledBySW = nav.workerStart > 0;
track("navigation_timing", {
type: nav.type, // navigate | reload | back_forward | prerender
handledBySW,
ttfb: Math.round(Math.max(0, nav.responseStart - activation)),
swAndResponse: handledBySW ? Math.round(nav.responseEnd - nav.workerStart) : null,
deliveryType: nav.deliveryType ?? null,
// Static Routing API: engines expose different subsets of these fields.
// Spec names (Safari 27) first, then Chromium's non-standard names.
routerMatched: nav.workerMatchedRouterSource ?? nav.workerMatchedSourceType ?? null,
routerFinal: nav.workerFinalRouterSource ?? nav.workerFinalSourceType ?? null,
routerEvalMs: nav.workerRouterEvaluationStart > 0
? Math.round(nav.workerRouterEvaluationStart - activation)
: null,
cacheLookupMs: nav.workerCacheLookupStart > 0
? Math.round(nav.workerCacheLookupStart - activation)
: null,
transferSize: nav.transferSize, // 0 often means cache (HTTP or worker)
});
}
// Run after load so the entry is complete.
if (document.readyState === "complete") reportNavigationTiming();
else addEventListener("load", reportNavigationTiming, { once: true });
Browser timing tells you how long. Only your worker knows what it did. Have the fetch handler label each navigation response and report it:
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
const started = Date.now();
let source = "network";
let response;
try {
response = await event.preloadResponse;
if (response) source = "preload";
else response = await fetch(event.request);
} catch {
response = await caches.match(event.request);
source = response ? "cache" : "offline-fallback";
response ??= await caches.match("/offline.html");
}
// Record after responding (recordSW comes from sw-push-analytics.js).
event.waitUntil(
recordSW("sw_navigation", {
path: new URL(event.request.url).pathname,
source,
ms: Date.now() - started,
})
);
return response ?? Response.error();
})()
);
});
That produces the most useful offline metric of all: sw_navigation events with source: "offline-fallback" are page views your users would otherwise have lost to the browser's error page. Sample the event (for example, 10% of navigations) on high-traffic sites.
Report Core Web Vitals with the same context dimensions so you can compare controlled and supported cohorts. Google's I/O web app analysis found a median first paint of 912 ms for pages not yet controlled by a service worker versus 583 ms for controlled pages on desktop, and 1,933 ms versus 1,634 ms on mobile (case study); your numbers will differ, which is why you measure. For single-page PWAs, the web-vitals library can report per-route metrics with its reportSoftNavs: true option (version 6 and later, according to Chrome's soft navigations documentation; the README's example loads the soft-navs build) in Chromium 151 and later, where soft navigation measurement is enabled by default. How CrUX will report soft navigations is, per the same documentation, still to be determined. With the option on, the initial URL's metrics are finalized at the first soft navigation, and each report carries a navigationURL you should use instead of location.href, because metrics can be reported after the route has changed again.
Privacy-friendly analytics¶
PWAs make it easy to collect too much: a persistent client, background events, and data that can be joined across launches. The design below collects what the questions on this page need, and nothing that identifies a person unless the user agrees. It's engineering guidance, not legal advice; your obligations depend on jurisdiction.
- First-party collection. Send to your own origin. Third-party collectors are blocked by content blockers and, with storage partitioning, can't recognize users across sites anyway.
- Session-scoped identifiers by default. Generate a random session ID per window in
sessionStorage. A long-lived ID inlocalStorageor IndexedDB (to count users) is a persistent identifier; create it only after consent. - No fingerprinting. Don't combine user agent, screen, fonts and similar signals into an identifier. Browsers actively work against it, and privacy law treats it like a cookie.
- Minimize at the edge. Drop or truncate IP addresses on arrival, don't store full user-agent strings when a parsed browser family and version suffice, and strip query strings except for known, non-personal parameters.
- Honor opt-out signals. Global Privacy Control is exposed as
navigator.globalPrivacyControl(Firefox 120+) and sent as theSec-GPC: 1request header by browsers and extensions that implement it.navigator.doNotTrackis deprecated. - Aggregate what you can. Counts of launches by display mode don't need per-user rows. Aggregate on the server and delete raw events on a short retention schedule.
const SESSION_KEY = "analytics.session";
const USER_KEY = "analytics.user";
function gpcOptOut() {
return navigator.globalPrivacyControl === true;
}
/** Random per-window session id. Never persisted beyond the window. */
export function getSessionId() {
try {
let id = sessionStorage.getItem(SESSION_KEY);
if (!id) {
id = crypto.randomUUID();
sessionStorage.setItem(SESSION_KEY, id);
}
return id;
} catch {
return null; // storage blocked: send events without a session
}
}
/** Long-lived pseudonymous id, only with consent and without a GPC opt-out. */
export function getUserId(hasConsent) {
try {
if (!hasConsent || gpcOptOut()) {
localStorage.removeItem(USER_KEY);
return null;
}
let id = localStorage.getItem(USER_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(USER_KEY, id);
}
return id;
} catch {
return null;
}
}
On iOS and iPadOS, a Home Screen app has its own storage, separate from Safari's, so the same person has different IDs in the browser and in the installed app. If you need to connect them (to attribute an install to a browser session), do it server-side through a signed-in account, and only with a lawful basis.
The collection endpoint¶
A minimal collector in Node.js with no dependencies. It accepts JSON batches from fetch(), sendBeacon(), keepalive and fetchLater(), validates them, corrects clock skew, deduplicates, and drops personal data it doesn't need.
// node server/collect.mjs (Node 18+). Put it behind your HTTPS reverse proxy
// on the same origin as the app, e.g. https://app.example.com/analytics/collect
import { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 8787);
const MAX_BODY = 1024 * 1024; // normal fetch batches can exceed the 64 KiB beacon cap
const MAX_AGE_MS = 72 * 60 * 60 * 1000;
const MAX_FUTURE_MS = 5 * 60 * 1000;
const NAME_RE = /^[a-z][a-z0-9_]{0,39}$/;
// Replace with a database table that has a unique index on event id.
const seen = new Map(); // id -> expiry
function isDuplicate(id, now) {
if (seen.has(id)) return true;
seen.set(id, now + MAX_AGE_MS);
if (seen.size > 500_000) {
for (const [key, exp] of seen) if (exp < now) seen.delete(key);
}
return false;
}
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY) throw Object.assign(new Error("Payload too large"), { status: 413 });
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
function normalize(event, offset, now, gpc) {
if (!event || typeof event !== "object") return null;
if (typeof event.id !== "string" || event.id.length > 64) return null;
if (typeof event.name !== "string" || !NAME_RE.test(event.name)) return null;
if (!Number.isFinite(event.ts)) return null;
const ts = event.ts + offset; // client clock -> server clock
if (ts < now - MAX_AGE_MS || ts > now + MAX_FUTURE_MS) return null;
const ctx = event.ctx ?? {};
return {
id: event.id,
name: event.name,
ts: new Date(ts).toISOString(),
receivedAt: new Date(now).toISOString(),
session: gpc ? null : typeof event.session === "string" ? event.session.slice(0, 64) : null,
props: event.props && typeof event.props === "object" ? event.props : {},
displayMode: String(ctx.displayMode ?? "unknown").slice(0, 32),
appMode: ctx.appMode === true,
launchSource: String(ctx.launchSource ?? "unknown").slice(0, 64),
swStatus: String(ctx.swStatus ?? ctx.source ?? "unknown").slice(0, 32),
swVersion: ctx.swVersion ? String(ctx.swVersion).slice(0, 64) : null,
online: ctx.online !== false,
lateBy: Math.max(0, Math.round((now - ts) / 1000)), // seconds: offline replay indicator
};
}
async function store(rows) {
// Replace with an INSERT ... ON CONFLICT (id) DO NOTHING into your warehouse.
for (const row of rows) process.stdout.write(JSON.stringify(row) + "\n");
}
const server = createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/analytics/collect") {
res.writeHead(404).end();
return;
}
// Fetch Metadata: browsers send Sec-Fetch-Site on beacons, keepalive and
// fetchLater requests. Reject cross-site posts that try to inject events.
const site = req.headers["sec-fetch-site"];
if (site && site !== "same-origin" && site !== "none") {
res.writeHead(403).end();
return;
}
try {
const now = Date.now();
const payload = JSON.parse(await readBody(req)); // beacons may arrive as text/plain
const events = Array.isArray(payload.events) ? payload.events.slice(0, 1000) : [];
const offset = Number.isFinite(payload.sentAt) ? now - payload.sentAt : 0;
const gpc = req.headers["sec-gpc"] === "1";
const rows = [];
for (const event of events) {
const row = normalize(event, offset, now, gpc);
if (row && !isDuplicate(row.id, now)) rows.push(row);
}
await store(rows);
// No IP address, cookie or full user agent is stored.
res.writeHead(204, { "Cache-Control": "no-store" }).end();
} catch (error) {
const status = error.status ?? (error instanceof SyntaxError ? 400 : 500);
res.writeHead(status).end();
}
});
server.listen(PORT, () => console.log(`collector on :${PORT}`));
The client treats 400 and 413 as permanent (the batch is dropped) and 5xx, 408 and 429 as temporary (the batch stays queued).
Forwarding to Google Analytics 4¶
If you report in Google Analytics 4, forward from the collector with the Measurement Protocol rather than sending from the client. The API secret stays on your server, replayed events keep their original timestamps, and GA's client script never has to work offline. Limits from Google's documentation:
| Limit | Value |
|---|---|
| Events per request | 25 |
| Parameters per event | 25 |
| Event and parameter names | 40 characters or fewer, alphanumeric and underscores, starting with a letter |
| Parameter values | 100 characters or fewer (500 for GA 360 properties) |
| Request body | smaller than 130 kB |
Backdating with timestamp_micros | up to 72 hours; older events are clamped or rejected depending on the property's validation behavior |
Google's examples also send session_id and engagement_time_msec in each event's params. Include them so that forwarded events are associated with sessions and engagement in GA4's reports; the forwarder below derives a numeric session_id from your own session ID.
const MEASUREMENT_ID = process.env.GA4_MEASUREMENT_ID; // "G-XXXXXXX"
const API_SECRET = process.env.GA4_API_SECRET; // never ship this to clients
/** rows: normalized events from collect.mjs that share one client/session id */
export async function forwardToGA4(clientId, rows) {
const url = new URL("https://www.google-analytics.com/mp/collect");
url.searchParams.set("measurement_id", MEASUREMENT_ID);
url.searchParams.set("api_secret", API_SECRET);
for (let i = 0; i < rows.length; i += 25) {
const batch = rows.slice(i, i + 25);
const body = {
client_id: clientId,
events: batch.map((row) => ({
name: row.name, // collect.mjs already enforces GA4's naming rules
timestamp_micros: Date.parse(row.ts) * 1000,
params: {
session_id: numericSessionId(row.session),
engagement_time_msec: 1,
display_mode: row.displayMode, // register as custom dimensions in GA4
launch_source: row.launchSource,
sw_status: row.swStatus,
...flatten(row.props, 25 - 5), // 25 parameters per event in total
},
})),
};
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// A 2xx only means "received": malformed events are dropped silently.
// Validate against /debug/mp/collect in development.
if (!res.ok) throw new Error(`GA4 Measurement Protocol: HTTP ${res.status}`);
}
}
const PARAM_NAME = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
/** Primitive props only, valid names, values cut to GA4's 100-character limit. */
function flatten(props, max) {
const out = {};
for (const [key, value] of Object.entries(props ?? {})) {
if (Object.keys(out).length >= max) break;
if (!PARAM_NAME.test(key)) continue;
if (typeof value === "string") out[key] = value.slice(0, 100);
else if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
else if (typeof value === "boolean") out[key] = String(value);
}
return out;
}
/** GA4 expects a numeric session id; derive a stable one from the UUID. */
function numericSessionId(session) {
if (!session) return undefined;
return parseInt(session.replace(/-/g, "").slice(0, 12), 16);
}
GA4 doesn't know about display mode or launch source. Register display_mode, launch_source and sw_status as event-scoped custom dimensions in the GA4 admin before they appear in reports. Validate payloads against the validation server (/debug/mp/collect) during development: Google's validation guide says that the Measurement Protocol "does not return HTTP error codes, even if an event is malformed or missing required parameters".
Debugging your instrumentation¶
- Network panel.
sendBeacon()requests appear with the typeping.keepaliveandfetchLater()requests appear asfetch. Enable Preserve log to see requests sent while navigating away. - Offline replay. In DevTools, set the network to Offline, generate events, check Application → IndexedDB → analytics → events, then go back online and watch the replay. See Browser DevTools.
- Background Sync and push. Chromium's Application → Background services panel records
sync, push and notification events for up to three days, even while DevTools is closed, once you start recording (docs). The Service workers pane can fire a testpushand asyncwith a given tag. - bfcache. Navigate away and back; you should see a
page_viewwithbfcache: trueand no duplicate initial page view. DevTools' Application → Back/forward cache test reports what blocks it (anunloadhandler is a common culprit). - Installed-app sessions. Install the app and launch it from the OS; check that
displayMode,appModeandlaunchSourceare right, then open a link from the app in the browser and check that they change. - Automated tests. Assert on the collector's received batches in end-to-end tests, including an offline phase (Automated Testing).
Common pitfalls¶
- Sending on
unloadorbeforeunload: unreliable on mobile, blocks the bfcache, and being removed from Chrome. - Treating
sendBeacon() === trueas "delivered". It only means "queued". - Adding a launch marker to
start_urlwithout an explicit manifestid, which changes the app's identity for existing installs. - Counting
display-mode: fullscreenas installed without checking for the Fullscreen API. - Replaying offline events with the replay time instead of the original timestamp, or without deduplication.
- An unbounded offline queue that grows until the origin's storage quota is exhausted.
- Loading a third-party analytics script from the network in the app shell: offline it fails, and cached it goes stale. Queue your own events instead.
- Expecting
appinstalledorbeforeinstallprompton Safari or Firefox. - Counting push opens only in
notificationclick, which misses Declarative Web Push on Safari. - Using a persistent user ID before consent, or ignoring
Sec-GPC.
Further reading¶
On this site
- Install Prompts & Custom UI: the install funnel and custom prompts
- Detecting Installed Apps: display mode,
navigator.standalone, related apps - Background Sync: the
syncevent used for replay - Push Notifications
- Measuring Performance
- IndexedDB
- Privacy & Storage Partitioning
- SEO for PWAs: keeping launch parameters out of the index
External references
- MDN:
Navigator.sendBeacon(),fetchLater()and Using Deferred Fetch - MDN:
display-mode - MDN:
Navigator.globalPrivacyControl - Chrome for Developers: Deprecating the unload event
- Chrome for Developers: Measuring soft navigations
- web.dev: Measuring the real-world performance impact of service workers
- web.dev: Navigation and Resource Timing
- W3C: Resource Timing
- Google Analytics: Measurement Protocol, sending events
- WebKit: Meet Declarative Web Push