Registering Listeners at the Top Level
Why every MV3 service worker event listener must be added synchronously on the first pass of the script — what goes wrong otherwise, and patterns that keep registration early as code grows.
Table of Contents
The single rule that prevents the largest share of Manifest V3 bugs is also the least visible: every addListener call in the service worker must run during the first synchronous execution of the script. A listener added after an await, inside a callback, or from a lazily imported module is added too late for the event that woke the worker — and the browser does not tell you. This guide is part of service worker fundamentals.
What the browser does when it wakes your worker
When an event arrives for an extension whose worker is not running, the browser starts the worker, evaluates the script, and then dispatches the event to whatever listeners exist at that moment. It does not wait for your promises to settle, and it does not queue the event for listeners added later.
Step-by-step
1. Put every registration in the entry module’s top level
1// service-worker.js — the complete registration surface, in one place
2import { onInstalled, onStartup } from "./lifecycle.js";
3import { onAlarm } from "./scheduler.js";
4import { onMessage } from "./rpc.js";
5import { onCommand } from "./commands.js";
6import { onMenuClick } from "./menus.js";
7
8chrome.runtime.onInstalled.addListener(onInstalled);
9chrome.runtime.onStartup.addListener(onStartup);
10chrome.alarms.onAlarm.addListener(onAlarm);
11chrome.runtime.onMessage.addListener(onMessage);
12chrome.commands.onCommand.addListener(onCommand);
13chrome.contextMenus.onClicked.addListener(onMenuClick);
Execution context: the top level of the service worker module. Static imports are resolved before the module body runs, so every handler function exists by the time these lines execute. Keeping all registrations in one file makes the rule auditable at a glance — a registration anywhere else is a smell.
2. Move initialisation inside the handlers
A handler that needs configuration should await it itself, not require it to be ready before registration.
1// config.js
2let configPromise = null;
3export function config() {
4 return (configPromise ??= chrome.storage.local.get("config").then((r) => r.config ?? DEFAULTS));
5}
6
7// scheduler.js
8export async function onAlarm(alarm) {
9 const cfg = await config();
10 if (alarm.name === "sync" && cfg.syncEnabled) return runSync(cfg);
11}
Execution context: the service worker. The memoised promise means the configuration is loaded at most once per worker lifetime, and every handler that needs it simply awaits the same promise. A returned promise from the listener keeps the worker alive until the work finishes.
3. Never register conditionally on async state
1// Broken: the listener exists only if storage said so — after the dispatch point.
2chrome.storage.local.get("features").then(({ features }) => {
3 if (features?.menus) chrome.contextMenus.onClicked.addListener(onMenuClick);
4});
5
6// Correct: register always, decide inside.
7chrome.contextMenus.onClicked.addListener(async (info, tab) => {
8 const { features } = await chrome.storage.local.get("features");
9 if (!features?.menus) return;
10 return onMenuClick(info, tab);
11});
Execution context: the service worker. An unconditional listener that returns early costs nothing measurable and is never missing. A conditional one works during development — when the worker is usually already awake — and fails in the field.
4. Register once, not per call
The mirror-image bug is registering inside a handler, which adds another listener every time that handler runs.
1// Broken: every sync adds another onChanged listener that lives until eviction.
2async function runSync() {
3 chrome.storage.onChanged.addListener(onSettingsChanged);
4 // …
5}
Execution context: the service worker. Within one worker lifetime this produces duplicate handling — two, then three, then ten calls per change — which looks like a flaky bug because eviction periodically resets the count.
5. Guard the rule in CI
1# Fail if addListener appears anywhere but the entry module.
2grep -rn "\.addListener(" src/worker --include=*.js \
3 | grep -v "^src/worker/service-worker.js:" && exit 1 || echo "listeners ok"
Execution context: your shell, in CI. Crude and effective: if listeners may only live in one file, a grep enforces it. Exceptions such as a scoped downloads.onChanged inside a promise that removes itself can be whitelisted by name.
Patterns that keep registration early as the code grows
The rule is easy to follow in a 200-line worker and gets harder at 5,000. Three structures scale.
A registry of handlers. Feature modules export handler maps; the entry module registers a single dispatcher per event that consults the maps. Features can be added without touching registration.
1// features/sync.js
2export const messages = { "sync:now": () => runSync() };
3export const alarms = { "sync": () => runSync() };
4
5// service-worker.js
6import * as sync from "./features/sync.js";
7import * as reader from "./features/reader.js";
8const FEATURES = [sync, reader];
9
10chrome.alarms.onAlarm.addListener((a) => {
11 for (const f of FEATURES) if (f.alarms?.[a.name]) return f.alarms[a.name](a);
12});
Execution context: the service worker. One listener per event, registered synchronously, dispatching to handlers that can be as asynchronous as they like — the same idea as the message registry in wrapping message passing in promises.
No top-level await in the entry module. An ES-module worker permits top-level await, and a single one in the entry module moves every registration after it past the dispatch point. Ban it by lint rule rather than by memory.
No dynamic import on the registration path. await import() is a top-level await in disguise. Lazy-load inside handlers if you must, never to obtain a handler.
Cross-browser variation
- Chrome / Edge: the strictest enforcement. Events are dispatched at the end of the first synchronous pass, and late listeners never receive the waking event.
- Firefox: the event page background follows the same rule for events that wake it. Because Firefox’s background stays resident longer, late registration fails less often, which makes it harder to catch there.
- Safari: the same rule, with a slower worker start. Safari is the most reliable place to reproduce a late-registration bug, because the worker is cold more often.
- All three:
runtime.onInstalledis the most costly event to miss — it fires once per install or update, and there is no second chance to run first-run setup or a migration.
Verification
- Stop the worker from
chrome://extensions, then trigger each event type — an alarm, a message from the popup, a menu click, a keyboard command — and confirm each is handled on a cold start. - Add a temporary assertion to detect late registration:
1let pastSyncPass = false;
2queueMicrotask(() => (pastSyncPass = true));
3const guard = (ev) => {
4 const add = ev.addListener.bind(ev);
5 ev.addListener = (fn) => { if (pastSyncPass) console.error("late listener", new Error().stack); add(fn); };
6};
7[chrome.runtime.onMessage, chrome.alarms.onAlarm, chrome.runtime.onInstalled].forEach(guard);
Execution context: the very top of the service worker in a development build. It must run before any other import has side effects, so place it in its own module imported first.
- Run the CI grep and confirm it passes.
- Install the extension fresh with DevTools closed and confirm first-run behaviour happened.
FAQ
Does this apply to Firefox’s browser.* listeners?
Yes. The namespace does not matter; the rule is about when the listener exists relative to event dispatch.
What about listeners in the popup or options page?
Those are ordinary documents that are not woken by events — they exist because the user opened them. Register where convenient; the rule is specific to the service worker.
Is removeListener safe to use?
Yes, for listeners with a deliberately short life — a one-off download watcher, for instance. It is never the fix for a duplicate-registration bug; registering once is.
Related
- Messages sent while the worker is starting — the messaging symptom of late registration.
- Using ES modules in an MV3 service worker — static imports, and why dynamic ones are a trap.
- Alarms that don’t fire after browser restart — a late onStartup listener in practice.
- Service worker fundamentals — the parent guide.