Chrome Extension Daily Active Users: How to Measure DAU, WAU and MAU
8 min readModerok team
Chrome gives extensions no daily active users number. How to define DAU, WAU and MAU and measure them from an MV3 service worker heartbeat.
There is no Chrome extension daily active users metric in the Web Store developer dashboard. The closest thing Google publishes is Weekly Users, and the store's own metrics page says the Users stats only capture installations and do not monitor whether users are active. To get DAU, WAU or MAU you have to emit a heartbeat from your own code and count distinct ids yourself. This post covers how to define those three numbers so they mean something, and how to produce them from an MV3 service worker.
TL;DR: Store a random profile id in
chrome.storage.local, send at most one ping per UTC day, and count distinct ids over a window. DAU is distinct ids in one UTC day. WAU and MAU are distinct ids over rolling 7 and 28 day windows, not the sum of the DAUs inside them. In MV3 the hard part is not the counting, it is deciding what "active" means when the service worker starts for reasons the user never sees.
Where a chrome extension daily active users number can come from
Three sources, and only one of them is yours to define:
| Source | What it gives you | Granularity |
|---|---|---|
| Web Store developer dashboard | Installs, Uninstalls, Weekly Users, Impressions | Weekly for users; no daily active figure |
chrome.runtime.setUninstallURL | A ping when a user removes the extension | Per removal |
| Your own instrumentation | Distinct profile ids active in any window you choose | Whatever you define |
Weekly Users can be broken down by country, language, operating system and item version, but not on the axis that matters here. You cannot ask it for yesterday, for a 28 day window, or split it by whether the person opened your popup. We covered what each store number counts in how many users does my Chrome extension have; this post starts where you have decided to measure activity yourself.
Decide what "active" means before you write the code
DAU, WAU and MAU are the same shape: distinct identifiers seen in a window. Two decisions matter: what the identifier is, and what counts as being seen.
The identifier is a random value your code generates on first run and persists. It is not a person: one person with a work profile, a personal profile and a laptop is three ids. Mirroring it through chrome.storage.sync can let it survive a profile wipe, but only when the user is signed in with Chrome Sync on, and that makes it more stable rather than closer to being a person.
Being seen is the part people get wrong. In a web app, "active" has an obvious anchor: a page load. An MV3 extension has none. Chrome's lifecycle documentation says a dormant worker is revived by an incoming event and goes dormant again after 30 seconds of inactivity. An alarm you scheduled or a message from a content script will start your worker whether or not the user is paying attention.
So pick one of two definitions and label it honestly:
- Active profile. The extension ran today, emitted on service worker startup. Stable, and closer to "installed and the browser was open" than to engagement.
- Engaged user. The person did something you count as use: opened the popup, ran the command, clicked the action. Emitted from that handler, so you control what qualifies.
Both are useful. Reporting the first and calling it engagement is how you get a DAU chart that never moves no matter what you ship.
A once-per-UTC-day heartbeat in MV3
The mechanics are a date stamp and a guard. Keep the stamp in UTC so a user travelling between timezones cannot produce two "days" or skip one.
// background.js, with "background": { "service_worker": "background.js", "type": "module" }
function utcDay(ms = Date.now()) {
return new Date(ms).toISOString().slice(0, 10); // "2026-09-28"
}
async function runHeartbeat() {
const today = utcDay();
const stored = await chrome.storage.local.get("activity");
const activity = stored.activity ?? {};
if (activity.lastPingDate === today) return;
// Mint and persist the id before the network call, so a failed ping
// does not hand this profile a fresh id on the next attempt.
let profileId = activity.profileId;
if (!profileId) {
profileId = crypto.randomUUID();
await chrome.storage.local.set({ activity: { ...activity, profileId } });
}
let res;
try {
res = await fetch("https://example.invalid/ping", {
method: "POST",
headers: { "Content-Type": "text/plain;charset=UTF-8" },
body: JSON.stringify({ profileId, day: today }),
});
} catch {
return; // offline, DNS, TLS: fetch rejects rather than returning a response
}
if (!res.ok) return; // do not advance the stamp; retry on the next wake
await chrome.storage.local.set({
activity: { ...activity, profileId, lastPingDate: today },
});
}
// One attempt in flight at a time, so the wakes below cannot each read
// "not pinged yet" and all post.
let inFlight = null;
function heartbeat() {
inFlight ??= runHeartbeat()
.catch(() => {}) // storage can reject too; do not leak an unhandled rejection
.finally(() => { inFlight = null; });
return inFlight;
}
chrome.runtime.onStartup.addListener(heartbeat);
chrome.runtime.onInstalled.addListener(heartbeat);
heartbeat(); // wakes that are neither startup nor install
Four details that are easy to get wrong:
- Register listeners at the top level, synchronously. A listener added after an
awaitmay not exist when the worker is revived for that event. - Only advance
lastPingDateafter the server accepts the ping. If you stamp the day first and the request fails, that profile is invisible for the day and nothing retries it. - Deduplicate concurrent wakes. On a cold start the bare call and the
onStartuplistener both run. Without the shared promise, both read storage before either writes and both post. - Only the
storagepermission is needed. Nohost_permissions, provided your endpoint returns the CORS headers; thetext/plaincontent type is there to avoid a preflight.
Register onStartup even though the bare call also fires. MDN's runtime.onStartup page makes the point that a background service worker needs a listener for this event to run at all in a session where nothing else wakes it. The same page says the event "is not fired when a private browsing (incognito) profile is started", so incognito-only sessions sit outside the count.
That gives you at most one successful ping per profile per UTC day, plus the occasional duplicate when a response is lost after the row was written. Deduplicate on (profile_id, day) server side and the rest is a query.
Computing DAU, WAU and MAU
DAU is a group-by:
SELECT day, COUNT(DISTINCT profile_id) AS dau
FROM pings
GROUP BY day
ORDER BY day;
WAU and MAU are the same query over a window, and this is where the common mistake lives:
-- correct: distinct profiles across the window (day stored as a DATE column)
SELECT COUNT(DISTINCT profile_id) AS wau
FROM pings
WHERE day > CURRENT_DATE - INTERVAL '7 days';
WAU is not SUM(dau) over seven days. Summing counts the same profile once per day it appeared, so a daily-use extension reports a WAU several times larger than the number of profiles that exist.
Prefer rolling windows over calendar weeks and months. A rolling 7 day window has the same number of weekend days every time it is computed, so it avoids the sawtooth calendar weeks develop. For the monthly figure use a fixed-length rolling window (28 or 30 days) rather than a calendar month, so a month-over-month change is a real change and not February being short. Twenty-eight has an extra property: every 28 day window holds exactly four of each weekday. Whichever you pick, put the length on the chart, because a 28 day and a 30 day MAU are not the same number.
DAU divided by MAU gives the stickiness ratio: the share of your monthly profiles that show up on an average day. For a passive extension (a blocker, a rewriter, anything that runs unasked) this tends to be high and says little. For one the user has to invoke, it is among the few numbers that respond to product work.
Five things that quietly break the count
- The worker never starts. A profile with no alarms, no matching content scripts and no interaction can go a whole day without waking your worker, and is absent from DAU despite the extension being installed. See why your MV3 service worker keeps stopping.
- Profile wipes. A Chromebook managed into ephemeral mode discards the profile at sign-out, taking
chrome.storage.localwith it, so the same student reappears as a new id next morning. Mirroring the id tochrome.storage.syncrecovers some of these when Chrome Sync is on. - Multiple profiles per person. Nothing available to an extension collapses these, so say "profiles" in the chart label.
- Local midnight versus UTC midnight. Stamp days in local time and a user who flies west can ping twice for one day. UTC costs nothing and removes the bug class.
- Comparing your number to Weekly Users. They are different denominators, over different windows, from different collection systems, on a day boundary Google does not document. Track each against its own history rather than against the other.
Getting it without building it
Moderok's SDK handles the identity and the once-a-day guard for you. It queues a __daily_ping during init(), documented as at most once per UTC day per user after startup, persists the profile id in chrome.storage.local, and can recover both the id and the ping date through browser sync after a profile reset. Like the snippet above, it holds the stamp back until the server accepts the ping. What it cannot do is wake your worker: it sees only the days something already started it, so the listeners you register still decide your coverage. The dashboard reports daily, weekly and monthly actives and a separate engaged-users metric, so background activity and the events you chose to send are not blended together.
If you would rather not build the pipeline, see what Moderok measures, or read the automatic events reference for which events arrive without you calling track().