Sending Analytics from a Service Worker with GA4

Send Google Analytics 4 events from an MV3 extension service worker with the Measurement Protocol: client_id and session_id in storage, engagement time, batching, the debug endpoint and consent gating.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The marketing site uses Google Analytics 4 and the team wants extension usage in the same property. The standard gtag.js snippet cannot be used: it is a remote script, which MV3 forbids, and it expects a page with window, document and cookies, none of which the service worker has. GA4’s Measurement Protocol is the supported alternative — a plain HTTPS endpoint that accepts JSON events — and it works from a service worker with fetch. Getting useful reports out of it requires supplying, by hand, the identifiers and engagement data that gtag.js normally manages. This guide does that. It belongs to usage analytics and feature flags.

What gtag.js did that you now do yourself

In a web page, gtag.js sets a first-party cookie holding a random client_id, starts a session with a session_id, measures engagement time while the page is visible, and attaches page context to every event. The Measurement Protocol accepts the same concepts as fields in a JSON payload but generates none of them. If you omit session_id and engagement_time_msec, GA4 records events but drops them from most engagement reports, and “active users” look far lower than reality. In an extension, client_id belongs in chrome.storage.local (persistent per install), session_id in chrome.storage.session (reset when the browser restarts, or after inactivity), and engagement time comes from how long your UI was actually open.

Fields a Measurement Protocol event needsmeasurement_id and api_secret in the URL, client_id per install, session_id per browsing session, engagement_time_msec per event, and the event name and parameters.measurement_id + api_secretquery stringidentify the streamclient_idrandom UUID per installstorage.localsession_idtimestamp, 30 min timeoutstorage.sessionengagement_time_msectime UI was openper eventname + paramsfrom your event schemano page data
Without session_id and engagement time, GA4 counts the events but hides them from engagement reports.

Step-by-step: GA4 from the service worker

1. Create a data stream and an API secret

In the GA4 admin, create a Web data stream for the extension (or reuse one), note its Measurement ID (G-XXXXXXX), and under Measurement Protocol API secrets create a secret. Both end up in your extension’s code.

1// analytics/ga4.js
2const MEASUREMENT_ID = "G-XXXXXXX";
3const API_SECRET = "abc123…";                       // ships in the package
4const ENDPOINT = `https://www.google-analytics.com/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`;

Execution context: a module imported by the service worker. The API secret is not truly secret — anyone can extract it from the package — and the worst someone can do with it is send fake events to your property. That is an accepted trade-off for client-side Measurement Protocol use; if fake data is a real concern, proxy through your own endpoint and keep the secret on the server.

2. Create a stable client_id per install

1export async function clientId() {
2  let { gaClientId } = await chrome.storage.local.get("gaClientId");
3  if (!gaClientId) {
4    gaClientId = crypto.randomUUID();
5    await chrome.storage.local.set({ gaClientId });
6  }
7  return gaClientId;
8}

Execution context: the service worker. A random UUID generated on first use identifies an installation, not a person, and cannot be linked to anything outside the extension. Never use the user’s Google account id, email, or a hardware identifier. Clearing extension data resets it, which is the correct behaviour.

3. Manage sessions in session storage

 1const SESSION_TIMEOUT_MIN = 30;
 2
 3export async function sessionId() {
 4  const now = Date.now();
 5  let { gaSession } = await chrome.storage.session.get("gaSession");
 6  if (gaSession && now - gaSession.lastSeen < SESSION_TIMEOUT_MIN * 60_000) {
 7    gaSession.lastSeen = now;
 8  } else {
 9    gaSession = { id: String(Math.floor(now / 1000)), lastSeen: now };
10  }
11  await chrome.storage.session.set({ gaSession });
12  return gaSession.id;
13}

Execution context: the service worker. chrome.storage.session survives worker eviction but not a browser restart, which matches GA4’s notion of a session; the thirty-minute inactivity timeout matches GA4’s default. The session id is a Unix timestamp in seconds, as gtag.js uses. Firefox supports storage.session from 115 and Safari from 16.4.

Building and sending one GA4 eventThe worker receives a tracked event, reads or creates client_id in local storage and session_id in session storage, adds engagement time, posts to the Measurement Protocol endpoint and receives 204.Service workerstorageGA4 /mp/collectget gaClientId (local)get/refresh gaSession (session)params + engagement_time_msecPOST {client_id, events:[…]}204 No Content
Two storage reads and one POST — and GA4 sees the event in engagement reports.

4. Send events with engagement time

 1export async function sendGa4(events) {
 2  const { consent } = await chrome.storage.local.get("consent");
 3  if (consent !== "granted" || events.length === 0) return;
 4  const body = {
 5    client_id: await clientId(),
 6    non_personalized_ads: true,
 7    events: await Promise.all(events.slice(0, 25).map(async (e) => ({
 8      name: e.name,
 9      params: {
10        ...e.params,
11        session_id: await sessionId(),
12        engagement_time_msec: e.engagementMs ?? 100,
13        extension_version: chrome.runtime.getManifest().version,
14      },
15    }))),
16  };
17  const res = await fetch(ENDPOINT, { method: "POST", body: JSON.stringify(body) });
18  return res.status === 204;
19}

Execution context: the service worker. The Measurement Protocol accepts up to 25 events per request; batch from your queue accordingly. The endpoint always returns 2xx for well-formed requests even when events are invalid — validation happens in step 6, not here. non_personalized_ads prevents the data being used for ad personalisation. A small non-zero engagement time is needed for events to count as engaged; pass real durations where you measure them.

5. Measure engagement where it happens

1// popup.js — measure how long the popup was open
2const opened = performance.now();
3addEventListener("pagehide", () => {
4  chrome.runtime.sendMessage({
5    type: "analytics:track", name: "popup_closed",
6    props: {}, engagementMs: Math.round(performance.now() - opened),
7  });
8});

Execution context: the popup. pagehide fires when the popup closes; a message sent from it usually reaches the worker, though delivery is not guaranteed as the page is torn down. For side panels and options pages, accumulate visible time with visibilitychange and send on hide. Never measure engagement inside content scripts — time on third-party pages is browsing behaviour, not extension engagement.

gtag.js versus Measurement Protocol in an extensionComparison of gtag.js and the GA4 Measurement Protocol on availability in MV3, identifier handling, session handling, engagement time and validation.Aspectgtag.jsMeasurement ProtocolAllowed in MV3No (remote script)Yes (fetch)client_idCookie, automaticstorage.local, manualsession_idAutomaticstorage.session, manualEngagement timeAutomaticMeasured by youError feedbackNoneDebug endpoint
The protocol works everywhere in MV3 — you supply what the snippet used to.

6. Validate payloads with the debug endpoint

1const DEBUG = `https://www.google-analytics.com/debug/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`;
2const res = await fetch(DEBUG, { method: "POST", body: JSON.stringify(body) });
3console.log((await res.json()).validationMessages);   // [] when valid

Execution context: the service worker in development builds only. The debug endpoint returns validation messages — reserved event names, parameters too long, missing fields — instead of silently accepting. Events sent there do not appear in reports. Run it in your test suite against a representative payload for every event in your schema.

Common mistakes

  • Loading gtag.js from a CDN. Blocked by CSP and a policy violation in review.
  • Omitting session_id or engagement_time_msec. Events arrive but most engagement reports exclude them.
  • Sending page URLs as page_location. That is browsing data; GA4 does not need it for extension analytics, and the store will ask why you collect it.
  • Sending from content scripts. Requests carry the page’s origin and look like third-party tracking on that site.
  • Ignoring consent. Every send path must check consent first, including the flush alarm.

Cross-browser variation

  • Chrome / Edge: works as written; add https://www.google-analytics.com/* to host_permissions or rely on its permissive CORS.
  • Firefox: works as written; AMO requires opt-in consent and disclosure for analytics, and newer Firefox versions expect a data-collection declaration in the manifest.
  • Safari: works with fetch; include analytics in the containing app’s App Store privacy label.

Verification

  1. Send a test event to the debug endpoint and confirm validationMessages is empty.
  2. In GA4, open Realtime and trigger an extension action: the event appears within a minute.
  3. In GA4’s DebugView, temporarily add debug_mode: 1 to params and confirm the session groups events correctly.
  4. Withdraw consent in the options page and confirm, in the worker’s Network panel, that no further requests go to Google Analytics.

FAQ

Can I combine extension and website data in one property?

Yes, as separate data streams in one property, distinguished by stream or by a custom dimension. Do not attempt to link the extension’s client_id with the website’s cookie — that cross-context linking is exactly what reviewers and privacy laws object to.

Is the Measurement Protocol rate-limited?

GA4 applies quotas per property; normal extension volumes are far below them. Batch events and send daily or hourly rather than per event.

You need a clear consent choice in the extension — typically in onboarding and the options page. The rules depend on jurisdiction and store; opt-in is the safe default.

Other Testing, Debugging & Performance Optimization Resources