GA4 Measurement Protocol in a Chrome Extension: Full Setup and What It Cannot Show You
8 min readModerok team
A working GA4 Measurement Protocol setup for an MV3 Chrome extension, the documented limits you will hit, and the extension metrics GA4 never sees.
Yes, the GA4 Measurement Protocol works in a Chrome extension, and it is the route Chrome's own Use Google Analytics guide points you to, as of September 2026. You POST JSON to https://www.google-analytics.com/mp/collect with a measurement ID and an API secret, straight from the MV3 service worker, with no library and no DOM. What it will not give you is the Chrome Web Store's install and uninstall numbers, and some of the event names you would reach for first are reserved. Full setup below, then the limits, each quoted from the Google page linked beside it and read in September 2026.
TL;DR: POST to
/mp/collectwithmeasurement_idandapi_secret, generate and persist your ownclient_id, and sendsession_idandengagement_time_msec, which Google's Measurement Protocol reference says, as of September 2026, is what keeps session and engagement metrics accurate. The Validate events page says, as of September 2026, that the protocol returns no HTTP error code for a malformed event, so test against the debug endpoint first. Installs and uninstalls as the Web Store counts them stay out of reach.
Why a Chrome extension has to use the GA4 Measurement Protocol
Chrome's Use Google Analytics guide states, as of September 2026, that "Since Manifest V3, Chrome Extensions are not allowed to execute remote hosted code," which rules out loading the tag from Google's servers. The service worker also has no document for a page tag to attach to. Both blockers are covered in why Google Analytics doesn't work in a Chrome extension. The Measurement Protocol sidesteps them, because it is an HTTP endpoint rather than a script.
You need two credentials: the measurement ID (G-XXXXXXXXXX) from your web data stream, and an API secret created under Admin, Data streams, Measurement Protocol API secrets. Google's Send Measurement Protocol events page says, as of September 2026, "The api_secret is private. Don't expose it in the client-side code of your website or app." An extension package is client-side code, so plan around that secret being readable by anyone who unzips your build.
The manifest and the client ID
Chrome's extension guide prints the permission list for this integration, as of September 2026, as "permissions": ["storage"], with no host_permissions. Dropped into a full manifest:
{
"manifest_version": 3,
"permissions": ["storage"],
"background": { "service_worker": "background.js", "type": "module" }
}
There are no cookies in a service worker, so the client ID is yours to generate and keep. The format is not free-form. The Measurement Protocol reference for web streams describes client_id, as of September 2026, as "Required. Identifier for a user instance of a web client," and lists two accepted formats: "Two positive numbers, joined by a period (.)" or "A client ID cookie." A UUID is neither. Chrome's extension guide therefore builds the first shape, a random number and a Unix timestamp in seconds, and its own comment says it uses "the <number>.<number> format since this is typical for GA client IDs":
function getRandomId() {
const digits = "123456789".split("");
let result = "";
for (let i = 0; i < 10; i++) {
result += digits[Math.floor(Math.random() * 9)];
}
return result;
}
async function getOrCreateClientId() {
const { clientId } = await chrome.storage.local.get("clientId");
if (clientId) return clientId;
const unixTimestampSeconds = Math.floor(Date.now() / 1000);
const id = `${getRandomId()}.${unixTimestampSeconds}`;
await chrome.storage.local.set({ clientId: id });
return id;
}
Mint it once and reuse it. A fresh one per send makes every event look like a different user.
Note where this lives. chrome.storage.local does not survive a reinstall, so a user who removes and re-adds your extension arrives as a new user. chrome.storage.sync behaves differently: the storage API reference says "If the user enables syncing, the data syncs with every Chrome browser that the user is logged into," so the ID can come back on another machine and quietly change what "a user" means in your reports.
Sending an event from the service worker
const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect";
const MEASUREMENT_ID = "G-XXXXXXXXXX";
const API_SECRET = "your_api_secret";
let sessionId;
function getSessionId() {
// One session id per service worker lifetime is the simplest option;
// persist it in chrome.storage.session if you want it to survive a
// service worker restart.
if (!sessionId) sessionId = String(Date.now());
return sessionId;
}
async function sendEvent(name, params = {}) {
const body = JSON.stringify({
client_id: await getOrCreateClientId(),
events: [
{
name,
params: {
...params,
session_id: getSessionId(),
engagement_time_msec: 100,
},
},
],
});
try {
await fetch(
`${GA_ENDPOINT}?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`,
{ method: "POST", body },
);
} catch (err) {
console.debug("GA4 send failed", err);
}
}
The two extra params are not decoration. The Measurement Protocol reference says, as of September 2026, "To ensure accurate session and user engagement metrics in your reports, including Realtime, include the session_id and engagement_time_msec parameters with your events." Omitting them puts exactly those metrics at risk while the events themselves keep arriving, which is a confusing thing to debug.
The try/catch matters more here than on a page: a rejected fetch in a service worker with nothing handling it is an unhandled rejection in a context you are not watching. And because Chrome tears the worker down when it goes idle, an in-memory array of pending events is gone on the next startup. If you batch, the batch has to live in chrome.storage.
Validate first, because the endpoint never complains
Google's Validate events page states, as of September 2026, "The Google Analytics Measurement Protocol does not return HTTP error codes, even if an event is malformed or missing required parameters," so a 2xx tells you the request arrived and nothing more.
There is a validation server for exactly this. Chrome's extension guide gives its URL, as of September 2026, as https://www.google-analytics.com/debug/mp/collect. Same request shape, but it returns the validation messages the production endpoint swallows. Point your dev build at it, then switch the constant back before you publish.
The documented limits
Every row comes from the page linked in it, read in September 2026.
| Limit | Value | Source |
|---|---|---|
| Events per request | at most 25 | Sending events |
| Parameters per event | maximum of 25 | Sending events |
| Event name length | 40 characters or fewer, alphanumeric and underscores | Sending events |
| Parameter value length | 100 characters or fewer (standard property) | Sending events |
| POST body size | smaller than 130kB | Sending events |
Backdating with timestamp_micros | up to 72 hours | MP reference |
| User-level data retention | 2 months or 14 months | Data retention |
The backdating window bites extensions specifically. A machine goes offline for four days, your persisted queue drains on the next startup, and the reference's "Events can be backdated up to 72 hours" (as of September 2026) no longer covers them if you replay with original timestamps. Send them without timestamps and they arrive stamped with whenever the replay happened, which is wrong in a quieter way.
What GA4 cannot show you about an extension
Installs and updates, under their natural names. The MP reference lists, as of September 2026, reserved event names that cannot be sent through the Measurement Protocol, and app_install, first_open and session_start are among them. You can send extension_installed as your own custom event from chrome.runtime.onInstalled, but it is your event, counted from your own listener rather than from the Chrome Web Store.
Uninstalls. Chrome's documented hook is chrome.runtime.setUninstallURL, and the runtime API reference describes it as setting "the URL to be visited upon uninstallation," notes "This URL must have an http: or https: scheme," caps it at "Maximum 1023 characters," and says "Set an empty string to not open a new tab upon uninstallation." What Chrome does with it is open a URL. There is no parameter for a request body or an HTTP method, so it cannot produce the POST that /mp/collect expects. You need your own endpoint in between, which means running a server.
Long user-level horizons. Google's data retention page says, as of September 2026, that user-level data can be retained for "2 months" or "14 months," and that the setting does not affect standard aggregated reports but does affect explorations and funnels. On the 2 month setting, a cohort exploration reaching back further has nothing to read.
When GA4 is the right choice
If you already run GA4 for a website and want extension events in the same property, next to the same conversions and audiences, the Measurement Protocol is the tool for that job. The same holds if your reason for picking GA4 is the rest of the Google stack around it, or if someone on your team already knows GA4 well.
The tradeoff is that you are building and maintaining an extension analytics client: an ID you persist, a queue that survives worker restarts, a retry policy, a validation step, and custom event names standing in for lifecycle facts GA4 has no concept of. We compared the two approaches end to end in Moderok vs Google Analytics, and surveyed the wider field in the best Chrome extension analytics tools.
If you would rather not build that layer
Moderok is an analytics platform built for browser extensions, so install, update, first open, a daily active ping, and errors arrive as automatic events without a track() call for each. The SDK is 5.7 kB gzipped (5,671 bytes, measured 2 August 2026), has zero runtime dependencies, and persists retryable failures so they can be retried with backoff after the worker restarts. Uninstall attribution is a config flag rather than a server you run.
Setup is three parts: install @moderok/sdk, add "permissions": ["storage"] to your manifest, and call init() at the top of your background service worker.
import { Moderok } from "@moderok/sdk";
Moderok.init({ appKey: "mk_ab12cd34ef56gh78" });
Have a look at what the dashboard shows, or read the getting started guide if you want to point it at your own extension.