Moderok

Blog

Why Google Analytics Doesn't Work in a Chrome Extension (Manifest V3)

Moderok team

Google Analytics gtag.js breaks in MV3 extensions: service workers have no DOM and Chrome bans remote code. Here's why, and what to use instead.

If you dropped the Google Analytics gtag.js snippet into a Manifest V3 extension and nothing showed up, it is not a configuration mistake. GA's web tag cannot run in an MV3 extension at all. There are two independent reasons: the background service worker has no DOM for gtag.js to attach to, and Chrome's Manifest V3 policy forbids loading the script from Google's servers in the first place. To send data to GA4 from an extension you have to skip the JavaScript library entirely and POST to the GA4 Measurement Protocol yourself. This post explains both blockers and shows the shape of the workaround.

Reason 1: the service worker has no window or document

The standard Google Analytics tag, whether the older analytics.js or the current gtag.js, is written for web pages. It reads and writes cookies, inspects document, measures the viewport off window, and generally assumes a live DOM.

In Manifest V3 your background logic runs in a service worker, and a service worker has none of those globals. There is no window, no document, no localStorage, and no cookies scoped the way a page has them. So even if you managed to get gtag.js into the worker, it would throw the moment it touched document. The web tag was never designed to run without a page around it.

You might think you can dodge this by loading GA in a content script or a popup, where window does exist. That runs into the second blocker.

Reason 2: Manifest V3 bans remotely-hosted code

The whole gtag.js integration works by loading a <script> from https://www.googletagmanager.com/gtag/js. Manifest V3 does not allow that. Chrome's Manifest V3 requirements state that all of an extension's logic must ship inside the extension package. Anything the browser executes that is fetched at runtime from another origin, JavaScript or WASM, is "remotely-hosted code," and it is prohibited.

That rule applies everywhere in the extension, not just the service worker. A content script or popup that injects the Google Tag Manager script is loading remote code just the same, and extensions have been rejected from the Chrome Web Store for exactly this. Bundling a copy of gtag.js locally does not help either, because the tag phones home and pulls more configuration and code at runtime. The library is designed around remote loading, which is the thing MV3 outlaws.

Between the two reasons, there is no arrangement, service worker, content script, popup, or offscreen document, where the normal GA tag is both functional and policy-compliant.

What actually works: the GA4 Measurement Protocol

Google's own guidance for extensions is to drop the library and use the GA4 Measurement Protocol, which is a plain HTTP endpoint you POST events to. No script to load, so no remote-code violation, and no DOM required, so it runs fine in a service worker.

The request looks like this:

const MEASUREMENT_ID = "G-XXXXXXXXXX";
const API_SECRET = "your_api_secret";

async function sendEvent(clientId, name, params) {
  await fetch(
    `https://www.google-analytics.com/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`,
    {
      method: "POST",
      body: JSON.stringify({
        client_id: clientId,
        events: [{ name, params }],
      }),
    },
  );
}

That is the core of it. But shipping this in a real extension surfaces three problems the tutorials tend to gloss over.

You have to invent and store the client_id yourself. On the web, gtag.js generates the client id and keeps it in a cookie. There are no cookies here, so you generate a random id once and persist it in chrome.storage.local, then read it back on every worker startup. If you skip this and mint a new id per event, GA4 counts every event as a different user and your user numbers become meaningless.

Your api_secret ships inside the extension. Anyone can unzip a published .crx and read it. The Measurement Protocol secret is not a strong credential to begin with, but be aware you are effectively publishing it, and that anyone who extracts it can inject fake events into your GA4 property.

Batching across service worker restarts is on you. Chrome tears the worker down after about 30 seconds of idle time. If you collect events in an in-memory array and flush them on a timer, the array is gone when the worker dies and those events never arrive. We covered this failure mode in detail in why your service worker keeps stopping: anything you want to survive a restart has to live in chrome.storage, not in a module-level variable. So a correct GA4 integration also needs a persistent queue that writes pending events to storage and drains them when the worker wakes.

None of this is impossible, it is just more plumbing than pasting a snippet, and it is plumbing you own and maintain.

Where Moderok fits

Moderok is built for this environment from the start, so the plumbing above is not your problem. The SDK is bundled into your extension package with zero remote code, which keeps you on the right side of the MV3 remote-code rule. It runs entirely in the service worker with no DOM, generates an anonymous id and persists it in chrome.storage.local for you, and queues events to storage so a worker shutdown never drops them.

A few other things it handles that a hand-rolled Measurement Protocol client does not:

  • It sends events with Content-Type: text/plain;charset=UTF-8, which avoids the CORS preflight that would otherwise fire from a service worker.
  • It needs only the storage permission and no host_permissions, so installing it adds no scary permission prompt.
  • Lifecycle events such as install, update, first open, a daily active ping, and errors are emitted automatically, without you writing a track() call for each.

Setup is one call at the top of your background worker:

import { Moderok } from "@moderok/sdk";

Moderok.init({ appKey: "mk_your_app_key" });

If your goal is understanding install and active-user trends rather than feeding a GA4 property, it is also worth reading how Chrome's own Weekly Users differs from installs, because the numbers you get from any client-side analytics will not match the dashboard's counts.

If you specifically need data in GA4, the Measurement Protocol is your path, and now you know the three gaps to fill. If you just want analytics that already understand MV3, try Moderok.