Counting Active Users Without Tracking
Measure daily and weekly active users of an MV3 extension without persistent identifiers: once-per-period pings with a local 'last reported' marker, cohort buckets, deduplication, and the store's own numbers.
Table of Contents
“How many people actually use this?” is the first question any stakeholder asks, and the store dashboard’s “users” number does not answer it: it counts installations that checked for updates, including browsers that are opened once a month and profiles nobody uses. The usual web answer — a persistent user id sent with every event, deduplicated on the server — gives you a number at the cost of a tracking identifier. There is a better way. Each installation can report once per day that it was active, with no identifier at all, and a local marker ensures it never reports twice. Summing those reports gives daily active users exactly. This guide builds it. It belongs to usage analytics and feature flags.
Why no identifier is needed
Server-side deduplication exists because the server cannot otherwise tell whether two reports came from the same client. If the client guarantees it reports at most once per period, the server does not need to deduplicate — it can simply count reports. The client keeps a local “last reported day” marker; when it is active on a new day, it sends one ping and updates the marker. Weekly and monthly actives work the same way with their own markers. The ping carries only coarse context worth segmenting by — extension version, browser family, and perhaps an install-age bucket — and nothing that persists across pings. The server learns how many installs were active, not which.
Step-by-step: identifier-free active users
1. Define what “active” means
1// activity.js — the only events that count as active use
2export const ACTIVE_SIGNALS = new Set([
3 "popup_opened", "side_panel_opened", "feature_used", "context_menu_clicked", "shortcut_used",
4]);
Execution context: a shared module. Being installed is not activity, and neither is the service worker waking for an alarm. Count only user-initiated interactions, so the number reflects people using the extension rather than browsers that happen to be running. Write the definition down; changing it later breaks the time series.
2. Record activity with period markers
1// sw.js
2function periods(now = new Date()) {
3 const day = now.toISOString().slice(0, 10);
4 const monday = new Date(now); monday.setUTCDate(now.getUTCDate() - ((now.getUTCDay() + 6) % 7));
5 return { day, week: monday.toISOString().slice(0, 10), month: day.slice(0, 7) };
6}
7
8export async function markActive(signal, sender) {
9 if (!ACTIVE_SIGNALS.has(signal) || sender?.tab?.incognito) return;
10 const { consent, reported = {} } = await chrome.storage.local.get(["consent", "reported"]);
11 if (consent !== "granted") return;
12 const p = periods();
13 const due = ["day", "week", "month"].filter((k) => reported[k] !== p[k]);
14 if (due.length === 0) return;
15 const ok = await sendPing(due);
16 if (ok) {
17 for (const k of due) reported[k] = p[k];
18 await chrome.storage.local.set({ reported });
19 }
20}
Execution context: the service worker, called from message handlers for UI events. UTC periods keep days consistent across time zones on the server. The marker is updated only after a successful send, so an offline day is reported when connectivity returns — as long as it is still the same day; earlier days are simply lost, which slightly undercounts but never double-counts.
3. Send a minimal ping
1async function sendPing(periodsDue) {
2 const body = {
3 p: periodsDue, // e.g. ["day", "week"]
4 v: chrome.runtime.getManifest().version,
5 b: browserFamily(), // "chrome" | "edge" | "firefox" | "safari" | "other"
6 age: await installAgeBucket(), // "0-7d" | "8-30d" | "31-180d" | "180d+"
7 };
8 try {
9 const res = await fetch("https://t.readable.example/v1/active", {
10 method: "POST", body: JSON.stringify(body), keepalive: true,
11 });
12 return res.ok;
13 } catch { return false; }
14}
Execution context: the service worker. One request covers every period that became due, so most days produce exactly one small request per active install. Coarse install-age buckets enable retention analysis by cohort (“of installs aged 8–30 days, how many were active this week?”) without any identifier. The server must not log IP addresses beyond what abuse prevention requires, and should drop them before storage.
4. Count on the server
1-- Daily, weekly and monthly actives by version and browser
2SELECT period_start, period, version, browser, COUNT(*) AS actives
3FROM active_pings
4GROUP BY period_start, period, version, browser;
Execution context: your backend. Store each ping as a row with its period start (derived server-side from the request time and the period names), version, browser and age bucket — nothing else. COUNT(*) is the active-user number, because each install sends at most one ping per period. Suppress small groups (fewer than about twenty) in published reports, as with any aggregated data.
5. Compare with the store’s numbers
1Store "users" installations that checked for updates recently
2Your weekly actives installations with a user interaction this week
3Ratio engagement: how many installs are really used
Execution context: a dashboard. The store’s user count is a useful upper bound and requires no telemetry at all; your weekly active count — limited to consenting users — is a lower bound on engagement. The ratio between them, tracked over time and corrected for your consent rate, tells you whether installs turn into use. A falling ratio after a release is often the first sign of a regression that users did not bother to report.
Common mistakes
- Counting service worker wake-ups as activity. Alarms and pushes wake the worker without the user doing anything.
- Setting the marker before the send succeeds. An offline day then disappears from the count and cannot be recovered.
- Adding “just a random id” for debugging. It reintroduces the identifier the design exists to avoid, and it will stay.
- Local-time days. Users in different time zones report different calendar days; use UTC consistently.
- Comparing raw actives across consent changes. A new consent prompt changes who reports; annotate dashboards when consent flows change.
Cross-browser variation
- Chrome / Edge:
fetchwithkeepalivefrom the worker; Edge installs are a separate store population — segment by browser family. - Firefox: same approach; under AMO’s opt-in requirement the counted population is consenting users only.
- Safari: works from the extension; App Store analytics for the containing app give a separate, app-level active count you can compare against.
Verification
- Use the extension several times in one day and confirm exactly one ping is sent (watch the worker’s Network panel).
- Change the system date to the next day and confirm one new daily ping.
- Go offline, use the extension, come back online the same day and use it again: one ping is sent.
- Use the extension in an incognito window and confirm no ping.
- Inspect a captured ping: only period names, version, browser family and age bucket.
Finally, decide in advance how you will present the number. “Weekly active installs among users who share usage data” is accurate; “weekly active users” invites readers to forget both the consent filter and the fact that one person with two browsers counts twice. Put the definition next to every chart that shows it.
FAQ
Is this exact?
For consenting, connected users it counts each active install once per period. It undercounts installs that were active only while offline for a whole period, which is usually a small effect.
Can I compute retention curves?
Cohort-level retention, yes, using install-age buckets. Per-user retention curves need identifiers, which this design deliberately avoids.
Do I still need consent for an identifier-free ping?
Store and legal requirements vary; many treat any usage telemetry as collection requiring disclosure and, in some places, consent. Gate it on consent to be safe.
Related
- Privacy-preserving usage metrics — the same principles for feature counts.
- Setting an uninstall survey URL — the other end of the user lifecycle.
- Alerting on error spikes after a release — normalising error counts by actives.
- Usage analytics and feature flags — the parent topic.