Plasmo Analytics: What Works in a Plasmo Extension's Service Worker
7 min readModerok team
Plasmo analytics: where init() has to live, the dev-mode service worker trap, the storage permission Plasmo will not generate, and the env var rule.
Plasmo ships no analytics module, so Plasmo analytics is ordinary code you place yourself, and three things in Plasmo's own docs decide whether it works: the file your initializer lives in, the permissions entry you have to add by hand, and the PLASMO_PUBLIC_ prefix your app key needs (Plasmo framework docs, as of October 2026). Get those right and any MV3-safe analytics SDK runs in a Plasmo extension as it would in a hand-written one. Get the first wrong and you silently lose install and update events, with no console error.
TL;DR: Put
init()at the top level ofbackground.ts, or ofbackground/index.tsif you use@plasmohq/messaging(messaging), because Chrome says a listener registered asynchronously "is not guaranteed to work in Manifest V3" (migrate to a service worker). Add"permissions": ["storage"]through themanifestfield inpackage.json(customization). Prefix the app key withPLASMO_PUBLIC_or it isundefinedat runtime (env). And do not trust a flush you watched underplasmo dev, where workers "will always remainactive" (background SW). All four as of October 2026.
The 30-second checklist
Each row is a fact from the Plasmo page linked in it, as of October 2026 (opened 26 October 2026).
| Plasmo behavior | What the docs say |
|---|---|
| Background entry file | "create a background.ts file in the root directory" (background SW) |
| Entry when using messaging | Handlers live in background/* folders; the worker must be at background/index.ts (messaging) |
| Worker lifetime | "idle after a few seconds of inactivity", killed "entirely after 5 minutes" (background SW) |
| Exception, in dev | "Service workers in Plasmo dev mode will always remain active" (background SW) |
| Env vars | "Only env vars prefixed with PLASMO_PUBLIC_ will be injected" (env) |
Plasmo analytics starts with no analytics package
Plasmo documents first-party packages for the two problems extensions always hit, @plasmohq/storage and @plasmohq/messaging, and no analytics equivalent among its framework features (storage, messaging, framework docs, all as of October 2026).
That differs from WXT, whose docs describe @wxt-dev/analytics as a module you add to wxt.config.ts to report events through a provider (wxt.dev/analytics, as of October 2026; provider source read in this comparison). In Plasmo nothing abstracts away MV3, so the SDK you pick has to survive the service worker on its own: no document, no window, no localStorage, no remotely hosted script tag (which permissions an analytics SDK needs). The upside: your analytics code is just a module import in a Plasmo entry file.
Where the initializer has to live
This is the one Plasmo-shaped mistake that costs you data.
Chrome says handlers "need to be declared in the global scope" so that "they are registered synchronously on initial script execution", "which enables Chrome to dispatch events to the service worker as soon as it starts". The migration guide adds that registering a listener asynchronously, for example inside a promise or callback, "is not guaranteed to work in Manifest V3" (events in service workers, migrate to a service worker, as of October 2026).
For analytics that event is chrome.runtime.onInstalled. A fresh install fires it once, while the worker is starting, and never again. So:
// background.ts (or background/index.ts if you have background/messages/*)
import { Moderok } from "@moderok/sdk"
Moderok.init({ appKey: process.env.PLASMO_PUBLIC_MODEROK_KEY! })
Not this, where the listener registers after the event has already fired:
import { Moderok } from "@moderok/sdk"
const start = async () => {
const settings = await loadSettings()
Moderok.init({ appKey: settings.key })
}
void start()
If you later add @plasmohq/messaging, its docs require the worker to live at background/index.ts with handlers in background/messages/* (messaging, as of October 2026), and that move is where people drop the init() call. Moderok's initialization guide spells out the same top-level rule.
The storage permission Plasmo will not add
Plasmo "generates the manifest under the hood based on your source files" (framework, as of October 2026), so it cannot know that an imported SDK writes to chrome.storage.local. Declare it yourself in package.json:
{
"manifest": {
"permissions": ["storage"]
}
}
Plasmo's docs use exactly this shape to add a permission it does not generate (customization, as of October 2026). storage produces no install prompt, and Moderok's docs say you "do not add host_permissions" for it (manifest and permissions).
Check the result: a production build lands in build/chrome-mv3-prod (framework, as of October 2026), so cat build/chrome-mv3-prod/manifest.json tells you what shipped.
The app key and the PLASMO_PUBLIC_ rule
Plasmo only injects env vars prefixed with PLASMO_PUBLIC_, and the docs show PRIVATE_KEY=xxx annotated as "Undefined in extension" (environment variables, as of October 2026). So a bare MODEROK_KEY in .env reaches nothing and init() receives undefined.
The prefix is a useful reminder: anything injected ships to users and is readable in the extension directory. An ingestion-only analytics key is fine there; an admin token is not. Plasmo also resolves .env.development and .env.production by NODE_ENV, and files ending in .local "have a higher priority than non-local ones" (env, as of October 2026), which keeps a personal key out of git while a shared one stays committed.
The dev-mode trap
Plasmo's docs say the worker "becomes idle after a few seconds of inactivity, and the browser will kill its process entirely after 5 minutes", so "all state (variables, etc.) is lost unless you use a storage engine". A separate note carves out one exception: service workers in dev mode "will always remain active" (background SW, as of October 2026).
That matters because batching and flush timers are in-memory. Under plasmo dev a 30-second flush interval always gets to run; in a real install the worker may be gone first, and what survives is whatever the SDK persisted. Moderok's queue persists retryable failures to chrome.storage.local and retries with backoff, but no SDK can promise nothing is ever dropped from a process the browser is free to kill.
So test instrumentation against a production build loaded unpacked from build/chrome-mv3-prod, and do not reach for a keep-alive to protect your flush: why a service worker keeps stopping covers why fighting the idle timer is the wrong fix.
Tracking from the popup and content scripts
Plasmo has more than one entry point. popup.tsx "exports a default React component, which gets rendered into your popup page" (framework, as of October 2026), and content scripts are a content.ts file or files in a contents directory, with an optional config export typed as PlasmoCSConfig (content scripts, as of October 2026). Each is a separate JavaScript context with its own module instances, so an SDK initialized in the background is not initialized in your popup.
Two workable shapes. Call track() in each context, which works because the Moderok SDK auto-initializes from the config the background saved to chrome.storage.local on its first run (initialization). Or funnel everything through the background, which is tidier if you want one place that knows your event names:
// background/messages/analytics.ts
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { Moderok } from "@moderok/sdk"
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
Moderok.track(req.body.name, req.body.props)
res.send({ ok: true })
}
export default handler
and from popup.tsx:
import { sendToBackground } from "@plasmohq/messaging"
await sendToBackground({
name: "analytics",
body: { name: "settings_saved", props: { theme: "dark" } }
})
The message name is the handler's file name (messaging, as of October 2026). Moderok's patterns guide describes the same background-only shape with plain chrome.runtime.sendMessage and notes that "the SDK does not include a message bus", so the framework's is the right one to use. Keep event properties flat: strings, numbers and booleans only.
Plasmo also builds firefox-mv2 and firefox-mv3, the latter marked experimental (FAQ, as of October 2026). Your analytics code does not change, but the runtime does: Firefox MV3 gives you an event page rather than a service worker (Firefox add-on analytics).
When writing it yourself is the right choice
If all you want is a daily heartbeat and an install count, you do not need an SDK. One chrome.runtime.onInstalled listener, one stored UTC date stamp and one fetch to an endpoint you own fits in a single background.ts, which is reasonable when you already run a backend or want fewer dependencies.
What you take on is everything off the happy path: retrying a send after the worker was killed, deduplicating a daily ping across concurrent wakes, not advancing the stored date when a request failed, and writing the queries that turn rows into retention numbers.
If you would rather not write them, Moderok is three steps in Plasmo: npm install @moderok/sdk, add "permissions": ["storage"] under the manifest key, and call Moderok.init() at the top of background.ts. Installs, updates, first open, daily activity and errors then arrive without a track() call (automatic events), from an SDK that is 5.7 kB gzipped with zero runtime dependencies. Start with the getting started guide, or see what the dashboard reports first.