• Analytics
  • Privacy
  • Chrome Web Store

PostHog in a Chrome extension: a Manifest V3 setup that passes review and keeps one user ID

Set up PostHog in a Chrome extension on Manifest V3: bundle it without remote code, share one distinct ID across contexts and avoid an inflated event bill.

Author
Extenify Team
Published
Reading time
12 min read
Bare footprints pressed into wet sand at the edge of the sea, with a thin sheet of water washing over the newest prints

Adding PostHog to a Chrome extension looks like a five-minute job: install posthog-js, call init, ship. Then the reports come in wrong. Every popup open counts as a new user, the service worker throws on window, the store rejects the package for remotely hosted code, or the bill shows millions of identify events from a few thousand people. None of this is a PostHog bug. A PostHog Chrome extension setup has to deal with four separate JavaScript contexts and Manifest V3's ban on remote code, and the defaults are tuned for a normal website.

This guide is the setup that works: which bundle to import, how to keep one distinct ID across the popup, service worker and content scripts, which events to send from where, and what to change so the Chrome Web Store review and your PostHog invoice both stay calm. The code targets posthog-js 1.438 (released October 6, 2026) and plain Manifest V3, with no framework assumptions.

Key takeaways

  • Import posthog-js/dist/module.no-external and set disable_external_dependency_loading: true. The default build lazy-loads code from PostHog's CDN, which Manifest V3 does not allow.
  • Generate one distinct ID, store it in chrome.storage.local, and pass it to every init call with bootstrap.distinctID. Without that, each context wakes up as a new anonymous user.
  • Use localStorage persistence on extension pages and memory in the service worker, which has no localStorage. Never use the default cookie persistence.
  • Send content script events through the service worker instead of loading PostHog into every tab.
  • Repeated identify() calls on every service worker wake-up are the classic cause of inflated PostHog bills in extensions.

Why PostHog in a Chrome extension is different

A website has one page and one lifecycle. A Manifest V3 extension has several, and each one gets its own JavaScript environment and its own storage:

Context Lifetime DOM localStorage Good for
Popup Seconds, while it is open Yes Extension origin Clicks in your UI
Options page, side panel While open Yes Extension origin Settings changes, longer sessions
Service worker Wakes on events, stops when idle No Not available Install, update, background jobs, relaying events
Content script One copy per matching tab The page's DOM The web page's origin In-page actions, session replay

A developer who wrote up their PostHog setup on DEV put the problem well: every analytics tutorial "assumed I was building a normal web app. One runtime. One page lifecycle." If you call posthog.init() in each context with the defaults, you get four unrelated anonymous users per person, and the service worker one is recreated every time Chrome restarts the worker.

Step 1: Install PostHog without remote code

Manifest V3 forbids executing code that is not in your package. The Chrome Web Store's Manifest V3 requirements say "the full functionality of an extension must be easily discernible from its submitted code", and list "including a <script> tag that points to a resource that is not within the extension's package" as a violation. A rejection for this arrives with the notification ID Blue Argon.

The regular posthog-js build lazy-loads extras such as surveys and session replay from PostHog's servers. The PostHog browser extension docs give the fix: import the no-external build and any extras you need as static bundles, and turn off external loading.

npm install posthog-js
// analytics/posthog.js: one place that knows how PostHog is configured.
import { PostHog } from 'posthog-js/dist/module.no-external';
// Optional extras, imported only where they are used:
// import 'posthog-js/dist/surveys';
// import 'posthog-js/dist/exception-autocapture';

export const POSTHOG_TOKEN = 'phc_your_project_token'; // public project token, safe to ship
export const POSTHOG_HOST = 'https://us.i.posthog.com'; // or https://eu.i.posthog.com

export function createPostHog(distinctId, overrides = {}) {
  const posthog = new PostHog();
  posthog.init(POSTHOG_TOKEN, {
    api_host: POSTHOG_HOST,
    disable_external_dependency_loading: true,
    persistence: 'localStorage',
    bootstrap: { distinctID: distinctId },
    ...overrides,
  });
  return posthog;
}

The project token is meant to be public. It can only send events, not read them, which is why it is fine in an extension, unlike the secret API keys covered in our guide on how to hide an API key in a Chrome extension.

If your minifier emits non-ASCII characters and Chrome refuses to load a file with an encoding error, the PostHog docs suggest configuring ASCII-only output (for esbuild, charset: 'ascii' is the default; for Terser, format: { ascii_only: true }).

Step 2: One distinct ID for every context

The PostHog docs are explicit that "instances in different contexts can't share the same storage for persistence", so you manage the ID yourself. Generate it once, store it in chrome.storage.local, which every extension context can read, and bootstrap each instance with it:

// analytics/distinct-id.js
const KEY = 'posthog_distinct_id';

export async function getSharedDistinctId() {
  const stored = await chrome.storage.local.get(KEY);
  if (stored[KEY]) return stored[KEY];
  const id = crypto.randomUUID();
  await chrome.storage.local.set({ [KEY]: id });
  return id;
}

Two contexts can race on the very first run and both generate an ID. Make that unlikely by creating the ID in chrome.runtime.onInstalled in the service worker, which fires as soon as the extension is installed, so that the popup and other pages almost always find an existing value.

When the user signs in to your service, call posthog.identify(userId) once and remember that you did. Do not call it on every startup: that is the billing trap described below.

Step 3: Initialize per context

Each context needs slightly different options. These follow the PostHog extension guide, trimmed to what matters.

// popup.js
import { createPostHog } from './analytics/posthog.js';
import { getSharedDistinctId } from './analytics/distinct-id.js';

const posthog = createPostHog(await getSharedDistinctId(), {
  capture_pageview: true, // one $pageview per popup open
  autocapture: false, // name the clicks you care about instead
});

document.querySelector('#export').addEventListener('click', () => {
  posthog.capture('export_clicked', { format: 'csv' });
});

Autocapture works here, but popups are small and short-lived. A handful of named events (export_clicked, settings_changed, upgrade_viewed) is easier to read in a funnel than hundreds of auto-captured clicks with generated selectors.

The service worker

The service worker has no DOM and no localStorage, so PostHog's guide sets persistence: 'memory' and turns off everything that needs a page: pageviews, autocapture, session recording and surveys. You can run posthog-js there with those options, or skip the SDK entirely and post to PostHog's capture API, which takes a project token, an event name and a distinct ID:

// background.js: a dependency-free sender for the service worker.
import { POSTHOG_TOKEN, POSTHOG_HOST } from './analytics/posthog.js';
import { getSharedDistinctId } from './analytics/distinct-id.js';

export async function track(event, properties = {}) {
  const body = {
    api_key: POSTHOG_TOKEN,
    event,
    distinct_id: await getSharedDistinctId(),
    properties: {
      ...properties,
      extension_version: chrome.runtime.getManifest().version,
    },
    timestamp: new Date().toISOString(),
  };
  try {
    await fetch(`${POSTHOG_HOST}/i/v0/e/`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body),
      keepalive: true,
    });
  } catch {
    // Offline or blocked: drop the event rather than retry forever.
  }
}

Every event carries extension_version, which makes "did the new release break the export flow" a one-click breakdown later.

Content scripts

Content scripts run once per matching tab, and their localStorage belongs to the website, not to you. Unless you need DOM-level features such as session replay on third-party pages, PostHog recommends relaying events "through a service worker using messaging to avoid importing a PostHog instance potentially into every tab the user has open". That also keeps your bundle out of every page:

// content-script.js
function track(event, properties) {
  chrome.runtime.sendMessage({ type: 'track', event, properties });
}
track('highlight_saved', { words: 42 });

// background.js
chrome.runtime.onMessage.addListener((message, sender) => {
  if (message?.type !== 'track' || sender.id !== chrome.runtime.id) return;
  if (!/^[a-z_]{1,40}$/.test(message.event)) return; // only your own event names
  track(message.event, message.properties ?? {});
});

Never put the page URL or page text in these properties unless you have disclosed it and truly need it. Chrome's security guidance also says to assume messages from a content script "might have been crafted by an attacker", which is why the listener checks the sender and the event name.

Step 4: Track installs, updates and uninstalls

Store dashboards tell you how many people installed. PostHog can tell you what happened next, if you send lifecycle events from the service worker:

// background.js
chrome.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => {
  const distinctId = await getSharedDistinctId(); // creates the ID on first install
  if (reason === 'install') {
    await track('extension_installed');
    chrome.runtime.setUninstallURL(
      `https://example.com/uninstalled?d=${encodeURIComponent(distinctId)}`,
    );
  }
  if (reason === 'update') {
    await track('extension_updated', { previous_version: previousVersion });
  }
});

The runtime API reference lists four reasons for onInstalled: install, update, chrome_update and shared_module_update, so check the reason instead of treating every call as an install. The uninstall URL is limited to 1,023 characters and opens in a new tab after removal. Have that page send an extension_uninstalled event with the ID from the query string, and you can build a retention cohort from install to uninstall. Our guide to measuring extension retention and uninstall rate covers how to turn those events into rates you can compare month to month.

Session replay and store review

Session replay is the feature most likely to get an extension rejected. PostHog's docs say so directly: the recording library bundled by default "contains parts of its code encoded in base64". The Chrome Web Store's code readability rule lists Base64 encoding as an example of obfuscation, with the rejection ID Red Titanium. A related posthog-js issue, opened in October 2024, reported that the worker created from a blob URL "violates the script-src policy we intend to apply to MV3 extensions".

If you need replay, import posthog-js/dist/posthog-recorder rather than the default recorder, as the PostHog docs advise. Then ask whether you need it at all. Recording third-party pages from a content script means recording what people do on other websites, which is a much bigger privacy disclosure than "counts feature usage". For most extensions, named events plus error tracking answer the real questions. Note that PostHog ignores exceptions thrown by extensions unless you set error_tracking: { captureExtensionExceptions: true }.

The billing trap: repeated identify calls

PostHog published a case study about a Chrome extension customer whose bill grew far beyond its real usage. The chain was:

  1. PostHog was re-initialized every time the extension woke up, without access to prior storage.
  2. Each initialization created a new anonymous distinct_id.
  3. The code called identify() whenever the anonymous ID differed from the stored user ID, which was every time.
  4. Each identify() reloaded feature flags, and those /flags requests counted towards the quota.

The warning signs PostHog lists are worth checking in your own project: identify making up 70 to 90 percent of all events, flag requests out of proportion to activity, and usage graphs that pulse at regular intervals. The fixes are the ones above: bootstrap a stored ID, call identify() only when the user actually changes, and pass known flag values with bootstrap.featureFlags instead of fetching them on every wake-up.

Privacy, disclosure and opt-out

Analytics in an extension is user data collection, and both big stores want it declared:

  • Chrome Web Store. Declare the data you collect on the Privacy practices tab and in your privacy policy. Our Chrome Web Store 2026 changes post covers the current rules.
  • Firefox. If you also ship to Firefox, usage analytics belongs in the optional technicalAndInteraction category of data_collection_permissions, and users can decline it at install. See our guide to Firefox data collection consent.
  • Opt-out. Give people a switch in your options page and call posthog.opt_out_capturing() when they turn it off. In the service worker sender above, check the same stored setting before calling fetch.

A setup that sends only named feature events, an extension version and a random ID is easy to explain and easy to defend in review. If you already use Google Analytics, our walkthrough of GA4 in a Manifest V3 extension solves the same multi-context problem with the Measurement Protocol.

Debugging checklist

When events do not show up, work through this list before changing code:

  • The project token and api_host region match (US and EU are separate).
  • No default-src or connect-src in your extension CSP blocks https://*.posthog.com.
  • posthog.debug(true) in the popup console shows requests going out.
  • posthog.get_distinct_id() prints the same value in the popup, the options page and the service worker log.
  • The service worker uses memory persistence and does not touch window.
  • Events from content scripts arrive with the shared ID, not one ID per website.
  • identify is less than a few percent of total events after a day of real traffic.

What PostHog cannot see

PostHog measures what people do after they have your extension. It cannot see the store: listing impressions, how many people viewed your page and did not install, your rating and review count, your category rank, or what a competitor shipped this week. Those numbers live on the Chrome Web Store, Edge Add-ons and Firefox Add-ons, and you need them next to your product events to explain a change. A drop in export_clicked means one thing if your weekly users fell and another if a rival just copied the feature.

That is the gap Extenify covers. The browser extension analytics dashboard records users, ratings, reviews, versions and category rank for any extension every day, merges the same extension across stores, and sends daily change alerts to email, Slack, Telegram, Discord or Teams. It needs no SDK, so it does not touch your package or your privacy disclosures. One extension is free, and the pricing page lists the plans with more tracked extensions and longer history.

Frequently asked questions

Can I use posthog-js in a Manifest V3 extension?

Yes. Import posthog-js/dist/module.no-external, set disable_external_dependency_loading: true, and use localStorage or memory persistence. The default build lazy-loads code from PostHog's CDN, which Manifest V3 extensions are not allowed to execute.

Why does every popup open show up as a new user?

Each context initializes its own PostHog instance with its own storage, so each one generates an anonymous ID. Store one ID in chrome.storage.local and pass it to every init call with bootstrap.distinctID.

Does PostHog work in the extension service worker?

Yes, with persistence: 'memory' and pageviews, autocapture, session recording and surveys turned off, because the service worker has no DOM and no localStorage. Posting to the /i/v0/e/ capture endpoint with fetch is a lighter alternative.

Will session replay get my extension rejected?

It can. The default recorder includes Base64-encoded code, which the Chrome Web Store treats as obfuscation. Import posthog-js/dist/posthog-recorder instead, and consider whether recording third-party pages is worth the extra disclosure.

Is it safe to put the PostHog project token in my extension?

Yes. The project token can only send events, not read data. Anyone can extract it, so people could send fake events to your project, but they cannot read your analytics. Keep personal API keys, which can read data, out of the package.

Summary

PostHog works well in a Chrome extension once you respect how extensions run. Import the no-external bundle and disable external loading so review passes. Create one ID in chrome.storage.local and bootstrap every context with it. Use localStorage persistence on extension pages and memory or the plain capture API in the service worker. Relay content script events through the worker, send install, update and uninstall events, call identify() only when the user changes, and declare what you collect. Then put your product events next to store data so you can tell a product problem from a store problem.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. 7 days free from your first extension, no card required, and you never touch your extension's code.

Start free