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, gateDecision) | defineStorageView: subscribe to the storage key from the UI | UI writing those keys; reading Firebase from a surface |
| a cohesive multi-method service | defineProxyService, and only then | wrapping single functions in a service |
| timers that outlive a worker activation | defineAlarm (chrome.alarms) | setTimeout/setInterval (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 gate 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, gateDecision, broadcasts, and logs 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, useCredits, and useGateDecision are the same idiom.
If you add background-owned state, this is how the UI reads it.
defineProxyService: only for multi-method services
Section titled “defineProxyService: only for multi-method services”// sharedexport const [registerMathService, getMathService] = defineProxyService("math", () => ({ add: async (a: number, b: number) => a + b }));// background (top level)registerMathService();// popup / content scriptawait getMathService().add(1, 2);Reach for this only for a cohesive service with several methods and shared setup. For one or two calls, use a typed message; the kit itself ships zero proxy services.
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; both are lint-banned in the background.
Alarms have a 30-second minimum period. Short timers within one
activation (a debounce, a UI delay) are fine.
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: { 2: (data) => ({ ...migrateV1toV2(data) }), },});It runs at every worker start (one cheap read when up to date) and applies pending migrations before anything hydrates.
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 → errors → logs → firebase (auth) →
billing → gates → broadcasts → update-notice. Add your feature as
a new module in that list; keep its listeners top-level and its state in
a store or storage view.