Background service worker
The MV3 background is an ephemeral service worker: Chrome kills it after ~30 s idle, caps tasks at ~5 min, and revives it on events. The kit ships one idiom per problem that survives this; this page is the decision table.
The one-idiom table
Section titled “The one-idiom table”| problem | use this | ignore |
|---|---|---|
| UI → background calls (request/response) | typed messages: the protocol in apps/extension/utils/messaging.ts | raw runtime.sendMessage |
| read-write app state shared across surfaces | defineStore (storage-backed, hydration-gated) | module globals as truth |
background-single-writer read-only state (user, entitlements, access, paywall) | defineStorageView: subscribe to the storage key from the UI | UI writing those keys; reading Firebase from a surface |
| timers that outlive a worker activation | defineAlarm (chrome.alarms) | setTimeout/setInterval (setInterval is lint-banned in the background) |
| stored-shape changes | defineMigrations: bump the version with a numbered migration | ad-hoc “if old shape” checks in readers |
Typed messages
Section titled “Typed messages”Every runtime message is declared once, in ExtensionProtocol
(apps/extension/utils/messaging.ts): key = message type, param =
payload, return = response. Both ends are typed:
// add to the protocolexport interface ExtensionProtocol { myFeatureRun(data: { input: string }): { ok: boolean };}
// background (top level):onMessage("myFeatureRun", async ({ input }) => ({ ok: input.length > 0 }));
// any UI surface or content script:const result = await sendMessage("myFeatureRun", { input: "hi" });Use this for everything that is “call the background, get an answer”: sign-in, checkout, and paywall access checks all work this way.
defineStore: read-write app state
Section titled “defineStore: read-write app state”For state any surface may write (settings is the kit’s example).
chrome.storage is the source of truth; the in-memory cache is a
rehydratable view. Changes propagate through storage.onChanged:
const settings = defineStore({ key: "settings", area: "local", defaults: { theme: "auto" } });await settings.ready; // every context gates on hydrationsettings.get().theme;await settings.set({ theme: "dark" });Call defineStore at the top level of the service worker; its listener
registers at define time. Use area: "session" for ephemeral or
token-ish data. storage.sync quotas: 100 KB total, 8 KB per item,
512 items, 120 writes/min.
defineStorageView: background-owned state, read from the UI
Section titled “defineStorageView: background-owned state, read from the UI”user, entitlements, access, paywall, and broadcasts are each
written by exactly one place in the background and only read everywhere
else. Surfaces subscribe to the storage key through a defineStorageView
(one initial read + one storage.onChanged subscription + a normalize
step, race-safe). The shape is useSyncExternalStore-compatible;
useAuth is the example:
const authView = defineStorageView<AuthView>( "user", (raw) => ({ loading: false, user: (raw as AuthUser | undefined) ?? null }), { initial: { loading: true, user: null } },);
const useAuth = () => { const { user, loading } = useSyncExternalStore(authView.subscribe, authView.getSnapshot); // …};useEntitlement, useFeature, usePlan, and usePaywall are the same idiom.
If you add background-owned state, this is how the UI reads it.
defineAlarm: the only timer that survives
Section titled “defineAlarm: the only timer that survives”defineAlarm("sync-entitlements", { periodInMinutes: 30 }, async () => { /* … */ });setTimeout/setInterval don’t survive worker restarts, and keepalive
intervals violate Chrome policy; setInterval is lint-banned in the background.
Alarms have a 30-second minimum period. Short timers within one
activation (a debounce, a UI delay) are fine.
The kit itself sets no alarm, so its manifest does not request the
alarms permission. Add it before your first defineAlarm call (the
/add-permission recipe): without it chrome.alarms is undefined and the
background stops at startup.
defineMigrations: versioned storage shapes
Section titled “defineMigrations: versioned storage shapes”chrome.storage carries data across extension updates; existing users
never get a fresh install. Any change to a stored shape means bumping the
version in entrypoints/background/migrations.ts with a numbered
migration:
defineMigrations({ version: 2, migrations: { 1: (data) => data, // the baseline the kit ships 2: (data) => ({ ...migrateV1toV2(data) }), },});It runs at every worker start (one cheap read when up to date), alongside
the other background modules, and writes only the keys a migration
changes. Await migrations.ready (the handle migrations.ts exports)
before reading a key a migration reshapes.
The two rules that break everything when violated
Section titled “The two rules that break everything when violated”- Register all listeners synchronously at the top level. An event
only revives the worker if its listener was registered during the
first synchronous evaluation of the script. A listener registered
inside an awaited init, a
.then, or asetTimeoutsilently misses the events that woke the worker. Top-levelawaitis disabled for the same reason; await hydration inside handlers (a store’sready). - Add the manifest permission before the code that needs it. A
missing permission makes the
chrome.*APIundefinedat module scope, the throw kills the entire background module graph, and every message from every surface hangs forever. Declare the permission inwxt.config.ts(and the owningmodule.json) in the same change. The e2e suite fails fast on this.
Background layout
Section titled “Background layout”The background is a module per concern
(apps/extension/entrypoints/background/), imported by index.ts in a
fixed order: migrations → the backend stack (#backend: its auth
strategy, email flows, and broadcasts mirror) → auth → entitlements
→ billing → prices → paywall → uninstall. Add your feature as
a new module in that list; keep its listeners top-level and its state in
a store or storage view.