2026-06-12 · 8 min read
Google Analytics doesn't work in Manifest V3 extensions. Here's why — and what to do.
If you shipped a browser extension before 2023, odds are your analytics was a copy-pasted Google Analytics snippet. It worked, roughly. Then Manifest V3 arrived, your background page became a service worker, gtag.js stopped loading, and your dashboard quietly flatlined. This article explains exactly what broke, what the workarounds actually deliver, and what a telemetry setup that survives MV3 looks like.
What exactly breaks
1. Remote code is banned.MV3's content security policy forbids loading and executing remotely hosted scripts. The standard GA4 integration is precisely that: a <script> tag pulling gtag.jsfrom Google's CDN. It will not load in your service worker, and bundling it doesn't help — the library expects a DOM, cookies, and a page lifecycle that don't exist there.
2. The service worker dies, and takes your batch with it. Chrome terminates an idle MV3 service worker after roughly 30 seconds. Any analytics library that buffers events in memory and flushes on a timer loses everything when the worker is killed. setIntervaldoesn't survive either — only chrome.alarms does.
3. Identity doesn't persist the way web analytics assumes. GA4 and most web tools key everything on a client ID stored in cookies or localStorage. Extension contexts — service worker, popup, options page, content scripts — don't share that storage. A content script's storage is partitioned per visited origin, so every site your extension touches becomes a brand-new "user". Your user counts inflate, your retention is fiction.
4. The store sees none of it. Even when events do arrive, GA4 has no notion of an install, an update, a version rollout, or an uninstall. The Chrome Web Store dashboard gives you rounded, days-late install counts — and developers have reported inconsistencies in store install analytics since the GA4 migration. Between the two, you have no reliable answer to the only questions that matter: how many real installs, how many actives, who uninstalled and when.
The Measurement Protocol workaround — and its limits
GA4's Measurement Protocol is a plain HTTP endpoint, so it does work from a service worker. You generate your own client ID, persist it in chrome.storage, and POST events to Google. If you are committed to GA4, this is the supported path.
Measurement Protocol — the minimumconst clientId = await getOrCreateClientId(); // chrome.storage, not cookies
await fetch(
"https://www.google-analytics.com/mp/collect?measurement_id=G-XXXX&api_secret=...",
{
method: "POST",
body: JSON.stringify({
client_id: clientId,
events: [{ name: "popup_open" }],
}),
}
);But notice what you just signed up for. You are now hand-rolling: client ID persistence, session attribution (MP events don't create sessions properly without extra parameters), batching, retry with backoff, and a queue that survives worker death. Your API secret ships inside the extension bundle, readable by anyone who unzips it. And at the end of all that work, GA4 still has no install/uninstall/version model — you get pageview-shaped analytics for a product that has no pages.
Generic product analytics: better, still not built for this
PostHog, Mixpanel and Amplitude all expose HTTP APIs that work in MV3, and PostHog documents extension usage honestly — including the sharp edges: you must manage distinct_id yourself across contexts, and getting it wrong both corrupts your data and, on per-event pricing, inflates your bill. These are good tools if you already run them for a web product and want one warehouse.
What none of them give you: uninstall tracking (there is no JavaScript left to run when the user removes your extension), version-rollout views, install-anchored retention cohorts, or a store-review-friendly privacy posture out of the box. Those have to be built around the tool, not with it.
What extension telemetry actually requires
A setup that survives MV3 and answers owner questions needs, at minimum:
- Persistent queue
- events in chrome.storage.local, not in memory
- Alarm-based flushing
- chrome.alarms, because timers die with the worker
- Retry + backoff
- respect retry-after; never lose a batch to a flaky network
- Install & update hooks
- runtime.onInstalled with version + previous version
- Uninstall tracking
- runtime.setUninstallURL hit server-side at removal
- Daily heartbeat
- one anonymous ping per install per day → real DAU/WAU/MAU
- Local aggregation
- count high-frequency actions locally, ship one daily rollup
- No PII, no fingerprinting
- random installation id — and an easy store review
You can absolutely build this yourself — plenty of teams have, usually twice: once quickly, then again properly after the first silent data loss. It is a real project with real maintenance, in exchange for owning every byte.
Or use something born for it
We built extension.report because we kept rebuilding that list. The SDK is MV3-native — storage-backed queue, chrome.alarms flushing, exponential backoff, install/update/uninstall wired automatically, daily heartbeats, and local daily counters so you can track everything without shipping everything. Remote config lets you tune sampling or kill telemetry entirely without re-submitting to the store. No cookies, no PII, one sentence in your privacy policy.
The whole integration, one callimport { initExtensionReport } from "@extension-report/js";
const sdk = initExtensionReport({ projectPublicKey: "pk_er_..." });Installs, actives, version spread, countries and uninstalls show up in a live dashboard — including the churn your store console will never show you.