Skip to content

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.

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 protocol
export 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.

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 hydration
settings.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:

// shared
export const [registerMathService, getMathService] =
defineProxyService("math", () => ({ add: async (a: number, b: number) => a + b }));
// background (top level)
registerMathService();
// popup / content script
await 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”
  1. 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 a setTimeout silently misses the very events that woke the worker. Top-level await is disabled for the same reason; await hydration inside handlers (a store’s ready). The kit’s helpers warn loudly if you register late.

  2. A new chrome.* API needs its manifest permission BEFORE the code lands. A missing permission makes the API undefined at module scope, the throw kills the entire background module graph, and every message from every surface hangs forever. Declare the permission in wxt.config.ts (and the owning module’s module.json) in the same change. The e2e suite loads the built manifest and fails fast on this.

The background is a module per concern (apps/extension/entrypoints/background/), imported by index.ts in a fixed order: migrationserrorslogsfirebase (auth) → billinggatesbroadcastsupdate-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).