How the background works
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 exactly 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; proxy services for one-off calls |
| read-write app state shared across surfaces | defineStore (storage-backed, hydration-gated); settings is the example |
module globals as truth; hand-rolled storage.onChanged wiring |
background-single-writer read-only state (user, entitlements, gateDecision, broadcasts, logs) |
defineStorageView: subscribe to the storage key from the UI; useAuth is the example |
UI writing those keys; reading Firebase/APIs from a surface |
| a cohesive multi-method service | defineProxyService, and only then |
wrapping single functions in a service; it’s extra indirection for no gain |
| 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 scattered through readers |
Typed messages – the default for UI → background
Section titled “Typed messages – the default for UI → background”Every runtime message is declared once, in ExtensionProtocol
(apps/extension/utils/messaging.ts): key = message type, param = payload,
return = response. Both ends are typed end to end:
// 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, gate checks all work this way. Never call raw
runtime.sendMessage.
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 just a
rehydratable view. Works in every context; 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/token-ish
data. Mind the storage.sync quotas if you use that area: 100 KB total,
8 KB per item, 512 items, 120 writes/min.
defineStorageView – background-single-writer state, read from the UI
Section titled “defineStorageView – background-single-writer state, read from the UI”The kit’s other state category is written by exactly one place in the
background and only read everywhere else: user (auth watcher),
entitlements (billing watcher), gateDecision (gate evaluator),
broadcasts, logs. Surfaces never write these and never talk to
Firebase/APIs for them. They 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, so the React layer is one line;
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 (core-billing) and useGateDecision (gate)
are the same idiom under the hood. If you add background-owned state, this
is how the UI should read it.
defineProxyService – only for multi-method services
Section titled “defineProxyService – only for multi-method services”The UI calls background functions as if they were local:
// 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 when you have 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; typed messages cover everything it does.
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 are a Chrome policy violation; 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; there is no “fresh
install” reset for existing users. 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 sequentially before anything hydrates.
The two rules that break everything when violated
Section titled “The two rules that break everything when violated”-
All listeners register synchronously at the top level. An event can only revive the worker if its listener was registered during the first synchronous evaluation of the script. Anything registered inside an awaited init, a
.then, or asetTimeoutsilently misses the very events that woke the worker. Top-levelawaitis disabled for the same reason; await hydration inside handlers (a store’sready). The kit’s helpers warn loudly if you register late. -
A new
chrome.*API needs its manifest permission BEFORE the code lands. A missing permission makes the 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 owning module’smodule.json) in the same change. The e2e suite loads the built manifest and 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 own feature
as a new module in that list; keep its listeners top-level and its state in
a store (or, if only the background writes it, expose it to the UI with a
storage view).
