daumauactive-userschrome-extensions

How to Measure DAU and MAU for a Chrome Extension (Without Pretending It's a Website)

DAU and MAU are the standard product-health numbers everyone reports — but the way they're defined for websites breaks in a Manifest V3 extension. The service worker dies, there is no continuous session, and the Web Store gives you nothing. Here's how to count active users correctly, with code that survives MV3.

DAU and MAU are the two numbers every investor, every team review, and every "how's the extension doing" conversation eventually lands on. For a normal SaaS product they're well-defined. For a Chrome extension they are quietly, catastrophically vague — the service worker dies every five minutes, there is no continuous browser session, and the Web Store dashboard counts installs, not actives. The result: most extension teams either pick a number that flatters them (install count) or one that under-counts them by an order of magnitude (popup opens). This guide is how to define and measure DAU/MAU honestly in Manifest V3, and what numbers are actually normal for an extension.

Why DAU is hard in an extension (and what most teams get wrong)

In a normal web app, DAU is simple: any session within the day counts. A session is a fresh page load (or a return after 30 minutes of inactivity). The browser keeps a tab open, your analytics SDK fires a heartbeat, you get a clean count.

An extension breaks every assumption in that sentence:

  • There is no "tab". Your code lives partly in a service worker that Chrome can terminate at any time, partly in content scripts injected into other people's pages, partly in a popup that exists for 800ms when the user clicks the toolbar icon. None of these has a session in the GA-shaped sense.
  • The service worker dies every ~30 seconds of idle. A user could have your extension installed and helping them all day without your background code being alive for more than a few seconds at a time. If you count by "worker uptime" you'll under-report by 80%+.
  • The Web Store dashboard counts installs. Installs are cumulative and one-time — they tell you about acquisition, not engagement. A user who installed a year ago and uses your extension daily looks identical to a user who installed yesterday and never opened the popup.
  • Popup opens are not DAU. Popular extensions (translators, blockers, dark-mode toggles) work entirely via content scripts. The user may never click the toolbar icon and still be a heavy daily user.

The fix isn't a clever single signal. It's a clear definition of "active", followed by two complementary signals that survive MV3. Get the definition wrong and no instrumentation will save you.

Pick a definition before you write any code

Three reasonable definitions of "active" for an extension. Pick one, write it on the wall, and don't change it without re-baselining:

  1. Present — the user had a browser session today while your extension was installed and enabled. Roughly the ceiling. Useful as a denominator, not as a product-health number.
  2. Engaged — your extension actually did something for the user today. The content script ran on a matching page, a feature fired, the popup was opened. This is the most useful definition for most extensions, and the one this guide optimizes for.
  3. Action-taken — the user performed a meaningful action your extension cares about (saved a clip, ran a translation, blocked an ad). Tighter than "engaged", better as a north-star metric.

For most extensions you want "Engaged DAU" as your headline metric and "Action DAU" as your conviction metric. Reporting "Present DAU" alone is the analytics equivalent of counting people who walked past your store.

The heartbeat pattern (the "is the user there" signal)

The simplest engaged-DAU signal is a heartbeat: when the user is actively browsing and your extension is loaded, fire one ping per session-with-a-day. Two rules make this honest:

  • Only fire when the browser is genuinely in use (not just window-open-in-background).
  • Dedupe at most once per anonymous_id × UTC day server-side, so you don't double-count.

Service workers can't hold a timer across wake-ups, but chrome.alarms can. Pair it with the content-script side firing on real user activity:

// background.js — fire at most once per day, dedup server-side anyway.
const HEARTBEAT_KEY = 'cx_last_heartbeat_utc_day';

chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create('cx-heartbeat', { periodInMinutes: 30 });
});

chrome.alarms.onAlarm.addListener(async (a) => {
  if (a.name !== 'cx-heartbeat') return;
  await maybeFireHeartbeat();
});

// Also fire on real interaction (cheap, idempotent because of the day-key check).
chrome.tabs.onActivated.addListener(() => maybeFireHeartbeat());

async function maybeFireHeartbeat() {
  const today = utcDayKey();
  const { [HEARTBEAT_KEY]: last } = await chrome.storage.local.get(HEARTBEAT_KEY);
  if (last === today) return;
  await chrome.storage.local.set({ [HEARTBEAT_KEY]: today });
  await sendEvent({
    name: 'ext.active',           // your "user was here today" event
    properties: { day_key: today },
  });
}

function utcDayKey() {
  const d = new Date();
  return d.getUTCFullYear() + '-'
    + String(d.getUTCMonth() + 1).padStart(2, '0') + '-'
    + String(d.getUTCDate()).padStart(2, '0');
}

The day_key property gives you a server-side idempotency anchor — even if the local storage gets wiped and the client fires twice, your aggregation can collapse on anonymous_id + day_key. The same dedup trick is the one we use for uninstalls — see install attribution for the broader pattern.

Critically: sendEvent uses navigator.sendBeacon (or keepalive fetch), not a plain fetch — otherwise the service worker can be terminated mid-request and the heartbeat is lost. The full survival pattern is covered in the service-worker survival guide.

The feature-use pattern (the "is the user using it" signal)

Heartbeat alone is the ceiling on engaged DAU. The conviction metric is feature use — actual evidence that your extension did the thing. For most extensions this is one or two events that represent the core value:

  • For a translator: translation.run.
  • For an ad blocker: block.matched (sampled, since it's high-volume).
  • For a notes/clip tool: clip.saved.
  • For a developer tool: panel.opened.

Pick the smallest set of events that, if all of them are zero for a user-day, you would honestly say that user did not use your extension that day. Then fire those events from the actual code paths:

// content.js — fired from the real interaction handler, not on inject.
async function onTranslateClick(text) {
  const result = await translate(text);
  showResult(result);
  // The "action" event — the conviction signal.
  sendEvent({
    name: 'translation.run',
    properties: {
      source_lang: detectedLang,
      target_lang: targetLang,
      char_count: text.length,        // bucketed, not raw
    },
  });
}

With these in place, Engaged DAU = unique anonymous_id with an ext.active OR any feature-use event in the day. Action DAU = unique anonymous_id with a feature-use event. Both roll up to WAU and MAU with the standard rolling-window math below.

Identity: who counts as "one user"

DAU only means something if "one user" is well-defined. In an extension the natural identifier is an anonymous_id stored in chrome.storage.local, generated on first wake. Four rules:

  • Generate once, never regenerate. Use crypto.randomUUID() in chrome.runtime.onInstalled and persist immediately. A regenerated ID inflates DAU and breaks retention math.
  • Use chrome.storage.local, not session or sync. session wipes on browser restart (deflates DAU); sync propagates the same ID across devices (deflates DAU the other way — one human, one ID, but you lose per-device behavior).
  • Don't hash anything user-controlled into it. The user is anonymous; the ID should be opaque. See privacy-first analytics on why.
  • Accept that incognito is a separate user. A new private window has its own storage. You will count it as a new user. That's correct — you can't identify the person, and you shouldn't.
// background.js — generate once.
const ANON_KEY = 'cx_anon_id';
async function getAnonymousId() {
  const { [ANON_KEY]: existing } = await chrome.storage.local.get(ANON_KEY);
  if (existing) return existing;
  const id = crypto.randomUUID();
  await chrome.storage.local.set({ [ANON_KEY]: id });
  return id;
}

DAU, WAU, MAU, and the stickiness ratio

Once you have an event stream of anonymous_id × day rows, the math is conventional:

  • DAU(d) = unique anonymous_id with at least one engagement event on UTC day d.
  • WAU(d) = unique anonymous_id with at least one engagement event in the trailing 7 days ending on d.
  • MAU(d) = unique anonymous_id with at least one engagement event in the trailing 28 days ending on d. Use 28, not 30 — clean weekly comparisons.
  • Stickiness = DAU / MAU. The single best one-number summary of how engaged your installed base is.

In SQL against an events table with columns project_id, anonymous_id, event_name, timestamp:

-- DAU for a project, last 30 days
SELECT
  date_trunc('day', timestamp AT TIME ZONE 'UTC')::date AS day,
  COUNT(DISTINCT anonymous_id) AS dau
FROM events
WHERE project_id = $1
  AND event_name IN ('ext.active', 'translation.run', 'clip.saved') -- engagement set
  AND timestamp >= now() - interval '30 days'
GROUP BY 1
ORDER BY 1;
-- Stickiness (DAU / MAU) as a rolling daily series
WITH engagement AS (
  SELECT
    date_trunc('day', timestamp AT TIME ZONE 'UTC')::date AS day,
    anonymous_id
  FROM events
  WHERE project_id = $1
    AND event_name IN ('ext.active', 'translation.run', 'clip.saved')
    AND timestamp >= now() - interval '60 days'
),
dau AS (
  SELECT day, COUNT(DISTINCT anonymous_id) AS n FROM engagement GROUP BY 1
),
mau AS (
  SELECT
    days.day,
    COUNT(DISTINCT e.anonymous_id) AS n
  FROM (SELECT DISTINCT day FROM engagement) days
  JOIN engagement e
    ON e.day BETWEEN days.day - interval '27 days' AND days.day
  GROUP BY 1
)
SELECT dau.day, dau.n AS dau, mau.n AS mau,
       round(100.0 * dau.n / NULLIF(mau.n, 0), 1) AS stickiness_pct
FROM dau JOIN mau USING (day)
ORDER BY 1;

DAU/MAU above 20% is genuinely sticky. Above 50% means the product is part of the user's daily workflow — rare and wonderful. Numbers below 5% mean most installs are dormant — the extension still works, but the user has forgotten or replaced it. That's a retention problem, not an instrumentation problem.

What numbers are actually normal

Treat these as ballparks, not laws — they vary wildly by category, install age, and how aggressive your activation funnel is. For per-category retention curves with charts, see the Chrome extension benchmarks reference page.

  • DAU/Installs ratio (engaged-DAU divided by total non-uninstalled installs): 10–30% for productivity tools, 40–70% for always-on tools (blockers, dark-mode, translators that auto-trigger), 5–15% for niche utilities.
  • DAU/MAU stickiness: 15–25% is healthy for a general utility; 30%+ is excellent; 50%+ is the "part of muscle memory" tier.
  • WAU/MAU: usually 60–80% for an engaged product. Lower than 50% means most monthly actives only show up once or twice a month — likely a power-user-vs-casual split worth segmenting.
  • D1 retention (came back the day after install): 30–50% is normal; 60%+ means your onboarding is unusually strong; below 25% means the user installed and never came back — typically a discoverability or first-run problem.
  • D30 retention: 15–25% is normal; 40%+ is rare and excellent.

These numbers also tell you when to stop obsessing over DAU and start obsessing over D7/D30 retention. If your DAU is flat but install count keeps growing, you have a churn problem underneath the install funnel — the only way to see it is the retention cohort, not the headline.

Common pitfalls (and how to spot them in your own data)

Pitfall 1: Counting ext.installed as activity

ext.installed fires on install — by definition it fires once per anonymous_id, ever. Including it in engagement events inflates DAU for new installers (they look active on install day) and then deflates it as they churn. Exclude it from engagement.

Pitfall 2: Firing on injection, not on use

If your content script fires ext.active the moment it's injected, every page load with a matching URL counts as activity — even if the user closed the tab without interacting. Common in URL-pattern-broad extensions. Fix: fire on actual user interaction (click, keypress, visible UI render), not on script load.

Pitfall 3: Background-tab inflation

Users leave tabs open for days. If your heartbeat fires from chrome.tabs.onActivated without checking document.visibilityState or any signal of real focus, you'll count people who haven't looked at their browser since Tuesday. The day-key dedup in the heartbeat pattern above hides most of this, but it's worth filtering on document.visibilityState === 'visible' before firing.

Pitfall 4: Multiple profiles, same human

A user with work + personal Chrome profiles is two anonymous_ids. You will count them twice. This is correct from a product-health perspective (two installs, two activations, two churn risks) but it does mean your DAU slightly over-counts humans. Don't try to de-dupe across profiles — anything that could would be a privacy violation.

Pitfall 5: Timezone drift around midnight

UTC-day bucketing is the only sane default for global products. If you bucket by local time, the user's timezone offset smears one day's activity across two buckets, which makes rolling DAU/MAU misleading. Use UTC for storage; show local time in the UI only.

Pitfall 6: SDK silently dropping events

The single most common reason a DAU number looks flat is the SDK is dropping events the dashboard never sees — a strict schema on the receiving side rejects the payload, or the service worker dies before fetch completes. Two checks: server-side, log validation rejections (don't silently 200 them); client-side, use navigator.sendBeacon with a persistent retry queue. The pattern is in our metrics tracking guide.

FAQ

Should I count uninstalls in DAU?

No. Uninstall is a one-time event per install — including it in engagement inflates the uninstall day and is meaningless. Track uninstalls separately and use them for churn/cohort analysis, not DAU — the four uninstall patterns and how to detect each are covered in why users uninstall your Chrome extension.

What about WAU/MAU for very new extensions?

If your extension is less than 28 days old, MAU = total engaged-users-to-date. Show DAU and WAU prominently; show MAU but flag it as "trailing 28 days (since launch)". Comparing MAU on day 5 to MAU on day 60 is the same as comparing your total install count over time — useful, but not engagement.

How often should the heartbeat fire?

Once per UTC day is enough for DAU. The heartbeat code above actually attempts to fire more often (on chrome.tabs.onActivated) but the day_key dedup collapses everything to one event per day per user. The extra attempts give you resilience: if storage is slow or the first attempt is killed, the next one catches it.

Can I use Google Analytics for DAU?

GA4 was redesigned around web sessions, and its measurement protocol is a poor fit for MV3 in general — see the GA4-in-an-extension post for the long version. You can force it to work, but the resulting numbers conflate page views with users and require a lot of custom-event plumbing to extract DAU cleanly.

How do I show DAU per extension version?

Send extension_version as a property on every event (from chrome.runtime.getManifest().version). Then group your DAU query by properties->>'extension_version'. This is how you spot the "v4.2 dropped DAU 15%" regression that no other tool will show you.

Should I exclude my own team from DAU?

Yes. Set a debug flag in storage during dev (chrome.storage.local.set({ cx_internal: true })) and add it as an event property; filter it out server-side. Otherwise your DAU is silently inflated by the people staring at it most.

DAU/MAU that knows it's an extension
Crxlytics computes engaged-DAU, action-DAU, WAU, MAU and stickiness with the MV3-aware heartbeat + feature-use model described above. Anonymous-by-default, version-split, with install-anchored retention cohorts. No remote code, no dropped events, no policy risk.
Get started free →