Skip to content

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.

problemuse thisignore
UI → background calls (request/response)typed messages: the protocol in apps/extension/utils/messaging.tsraw runtime.sendMessage
read-write app state shared across surfacesdefineStore (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 UIUI writing those keys; reading Firebase from a surface
a cohesive multi-method servicedefineProxyService, and only thenwrapping single functions in a service
timers that outlive a worker activationdefineAlarm (chrome.alarms)setTimeout/setInterval (lint-banned in the background)
stored-shape changesdefineMigrations: bump the version with a numbered migrationad-hoc “if old shape” checks in readers

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 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, and gate checks all work this way.

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 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 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”
// 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 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("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”
  1. 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 a setTimeout silently misses the events that woke the worker. Top-level await is disabled for the same reason; await hydration inside handlers (a store’s ready).
  2. Add the manifest permission before the code that needs it. A missing permission makes the chrome.* 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.json) in the same change. The e2e suite 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 feature as a new module in that list; keep its listeners top-level and its state in a store or storage view.