chrome-extensionsanalyticsmanifest-v3how-to

How to Track Chrome Extension Metrics: A 2026 Developer Guide

A practical guide to tracking installs, retention, feature usage, errors, and uninstalls in a Manifest V3 Chrome extension — with code, MV3 gotchas, and privacy notes.

You shipped a Chrome extension. It works. Now the next obvious question: is anybody actually using it? And which parts? This guide walks through every metric worth tracking in a Manifest V3 extension, the MV3-specific gotchas that sink most DIY attempts, and the exact code patterns that survive service-worker termination.

Why Chrome extension analytics is its own thing

A standard web app runs on https://yourdomain.com. A Chrome extension runs on chrome-extension://<id> for its popup and options page, in a separate service-worker context for the background, and as injected content scripts on every site the user visits. None of those environments behave like a regular web page.

Generic analytics libraries — Google Analytics 4, Plausible, Mixpanel — were not designed for any of this. They assume a top-level document, a referrer header, a long-lived window, and a single origin. In an extension, every one of those assumptions breaks. That is why a dedicated Chrome extension analytics SDK exists — and why most people who try to bend GA4 into the role give up after a week.

The three concrete problems any tool has to solve:

  • Service-worker termination. Manifest V3 extensions do not have a persistent background page. Chrome stops the service worker after about 30 seconds of inactivity. Any in-memory queue, timer, or unsent batch dies with it.
  • Listener registration timing. Event listeners (for install, uninstall, errors, etc.) must be registered synchronously during the worker's initial script evaluation. If you wait for an await first, Chrome may have already missed the event when it wakes the worker for it.
  • No referrer, no Chrome Web Store attribution. The store does not pass UTM parameters or click sources through to your extension. You have to reconstruct them from the install timestamp and the landing page UTM parameters yourself.

The metrics that actually matter

Resist the urge to track everything. For most extensions, six numbers carry 95% of the signal:

  1. Installs. How many people added the extension this week. Always paired with the source — Chrome Web Store search, direct, your landing page, a partner.
  2. DAU / WAU / MAU. Unique users active on any given day, week, or month. Defines the size of the audience that's actually using the product.
  3. D1, D7, D30 retention. Of users who installed N days ago, how many are still active today. This is the single most important number for a freemium extension — if it falls under 20% at D7, fixing acquisition is a waste.
  4. Feature usage. Which feature is used most, by how many users, how often. Tells you where to invest engineering time and what to put in front of new users.
  5. Errors. Stack traces broken down by browser version, extension version, and site. MV3 extensions hit a lot of weird edge cases (page navigation mid-injection, CSP violations, permission revocations) — silent failures are very common.
  6. Uninstalls. How many people removed the extension, and if possible what they were doing right before they uninstalled. The single highest-leverage piece of qualitative data you can collect.

Anything beyond those is a nice-to-have. Resist building dashboards before you have the basics flowing.

MV3 gotchas you will hit

1. Register listeners synchronously

Chrome wakes your service worker, gives it a few hundred milliseconds of synchronous execution, and only after that fires the event you registered for. If your listener registration happens behind an await, the worker is already past that window — you miss the event entirely.

// background.js — service worker
// ❌ Wrong: listener registered after await
const config = await fetch('/config.json').then(r => r.json());
chrome.runtime.onInstalled.addListener(handler);

// ✅ Right: listener registered first, synchronously
chrome.runtime.onInstalled.addListener(handler);
const config = await fetch('/config.json').then(r => r.json());

2. Persist queued events to chrome.storage

If you batch events to reduce network calls (you should), keep the queue in chrome.storage.local, not memory. When the worker terminates mid-batch, an in-memory queue is gone. A storage-backed queue gets restored the next time the worker wakes.

3. Use chrome.alarms for flushing, not setInterval

setInterval dies with the service worker. chrome.alarms wakes the worker on schedule even when it's been suspended. For a flush-every-30s pattern, alarms are the only correct primitive.

chrome.alarms.create('crxlytics-flush', { periodInMinutes: 0.5 });

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'crxlytics-flush') flushQueue();
});

4. Content scripts can't hit external APIs directly

Content scripts run on third-party origins and inherit their CORS rules. Send events from the service worker only — content scripts should forward messages via chrome.runtime.sendMessage.

These four points cover the failure modes that bite most DIY implementations. If you want a deeper dive into MV3-specific constraints, see our breakdown of why most analytics libraries silently break in service workers (linked in the related guides at the bottom).

Tracking installs and updates

Chrome fires chrome.runtime.onInstalled with a reason that's either install, update, chrome_update, or shared_module_update. Most extensions only care about the first two.

chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    sendEvent('ext.installed', {
      previous_version: null,
    });
  } else if (details.reason === 'update') {
    sendEvent('ext.updated', {
      previous_version: details.previousVersion,
    });
  }
});

The hard part is attribution. Chrome doesn't tell you where the install came from. The standard approach: on your landing page, persist UTM parameters in a first-party cookie or localStorage. When the extension fires its first event, pass the anonymous user ID up. Then match installs back to landing-page visits by timestamp + browser fingerprint window (5 minutes is usually enough).

If you don't want to build that, our Crxlytics SDK does it for you — it ships with a pixel endpoint your landing page hits on click, and the API reconciles installs to attribution links inside a 30-day window.

Tracking feature usage

Decide which features matter before you start instrumenting. A typical extension has 10–30 user-facing features. Pick the 5–8 that map to your value proposition and instrument those first.

The naming convention that pays off long-term: noun.verb in lowercase, scoped by area.

// In your content script or popup, after the user takes an action:
chrome.runtime.sendMessage({
  type: 'crxlytics.track',
  event: 'subtitle.dual_enabled',
  properties: { video_id: currentVideoId, language: targetLang },
});

// In the service worker, forward to the analytics SDK:
chrome.runtime.onMessage.addListener((msg) => {
  if (msg?.type === 'crxlytics.track') {
    self.Crxlytics.track(msg.event, msg.properties);
  }
});

Three rules that scale:

  • Don't over-property. Two or three properties per event is plenty. The more you add the harder the schema gets to maintain.
  • Avoid PII as property values. Never put email, IP, or raw user input in event properties. Use hashed IDs or enums.
  • Keep event names stable. Renaming subtitle.dual_enabled to subtitles.dual.enable later breaks every funnel. Pick a convention and live with it.

Tracking errors safely

Error tracking in MV3 has one trap: the global error listener has to be registered synchronously, before the worker hits its first await. Sentry-style libraries that lazy-init miss the first error of every worker wake-up.

// Top of background.js — runs synchronously on every wake
self.addEventListener('error', (event) => {
  sendEvent('error.occurred', {
    error_message: event.message,
    filename: event.filename,
    lineno: event.lineno,
  });
});

self.addEventListener('unhandledrejection', (event) => {
  sendEvent('error.occurred', {
    error_message: String(event.reason),
    type: 'unhandled_rejection',
  });
});

For content scripts, do the same in each script's top frame — but forward via chrome.runtime.sendMessage so the actual network call happens in the worker.

One more detail: Chrome strips stack-trace file paths to chrome-extension://<id>/..., which makes source-map upload mandatory if you minify. If you don't ship source maps, your error traces are usable but hard to act on.

Tracking uninstalls and reasons

The only signal Chrome gives you about an uninstall is chrome.runtime.setUninstallURL: when the user removes the extension, Chrome opens that URL in a new tab. Set it during init to a page you control, then track the page load as the uninstall event.

chrome.runtime.setUninstallURL(
  'https://api.crxlytics.com/v1/uninstall' +
  '?uid=' + encodeURIComponent(anonymousId) +
  '&v=' + encodeURIComponent(chrome.runtime.getManifest().version)
);

The interesting part is what you do after the redirect. If you redirect the user to a feedback page on your own domain (with a 4-option survey: confused, broken, not useful, switched tool), you can collect qualitative uninstall reasons. The fact that the user just removed your extension is the highest-intent moment they will ever have to tell you why — most will skip, but the 10–20% who answer give you product-level signal nothing else can.

We wrote up the post-uninstall page pattern (with the exact snippet) in the integration prompt; you can also see it surface directly in the Crxlytics Uninstalls dashboard.

Privacy and Chrome Web Store policy

Three rules that keep you out of trouble:

  • Disclose what you collect in the CWS listing. The store's "Privacy practices" tab is mandatory. Selecting "Web history" or "User activity" requires a published privacy policy URL.
  • Use anonymous IDs only by default. A UUID generated at first run, stored in chrome.storage.local, is enough for retention and cohorts. You do not need an email or a fingerprint.
  • Don't store IPs. If you need country, derive it from navigator.language or do a server-side GeoIP lookup that doesn't persist the IP.

Most violations don't come from analytics tracking itself — they come from undisclosed third-party SDKs (looking at you, GA4) that send the full URL of every page the user visits. Stay native to your own domain and you avoid the worst category of CWS rejections.

What to do with the data

Two patterns separate teams that learn from analytics from teams that just collect it:

Pattern 1: One weekly metric review

Pick a 30-minute slot every Monday. Look at four numbers: new installs, D7 retention, top-3 feature uses, errors. Write one sentence about each. That's it. The discipline of writing the sentences forces you to notice the patterns.

Pattern 2: Funnel against your activation event

Pick the single moment a user becomes activated — the first time they successfully use the core feature. For a translation extension, it's the first translation. For a password manager, it's the first saved credential. Track:

  1. Installs.
  2. Reached the relevant page.
  3. Triggered the feature.
  4. Completed the feature.

Wherever the biggest drop is, that's your next product fix. Repeat monthly.

FAQ

Can I use Google Analytics 4 for my Chrome extension?

Technically yes — but you will fight it. GA4 was built for top-level documents, so the SDK assumes a referrer header and a stable location. In an extension popup, it misattributes traffic. In content scripts, you risk Chrome Web Store policy violations because GA4 sends the full visited URL upstream. See our comparison of analytics tools for a side-by-side.

How big should my analytics SDK be?

Under 10 KB. Anything larger eats into your extension's cold-start budget. Our SDK is about 5 KB gzipped. If yours is bigger, audit what's in there — most generic SDKs ship polyfills, fetch wrappers, and feature-detection code that modern browsers don't need.

Do I need a backend, or can I just send to a hosted tool?

For most cases, hosted is the right answer. Building the queue, retry logic, attribution windows, and cohort math from scratch takes weeks. Hosted tools like Crxlytics handle all of it, charge per event volume, and give you a dashboard.

How often should I flush events?

Every 30 seconds, or when the queue hits 20 events — whichever comes first. Add a fast-path flush on chrome.runtime.onSuspend to catch the worker before it goes to sleep. That covers the 99% case without hammering the network.

Should I track every page the content script touches?

Only if you need it for the "last domain visited" before-uninstall signal. Tracking every page view from content scripts is high-volume and rarely useful for product analytics. Track the actions that matter, not the navigation.

Wire up tracking in 5 minutes
Drop in a 5 KB SDK built for MV3 service workers — installs, retention, feature usage, errors, and uninstalls out of the box.
Get started free →