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.

Published September 18, 2026 Updated September 18, 2026 7 min read
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.

Dispatch happens after the first synchronous passAn alarm wakes the worker; the script's synchronous top level registers two listeners, then an await is reached, the event is dispatched to the listeners that exist, and a listener registered after the await never sees it.BrowserWorker top levelEarly listenersLate listenerstart worker for onAlarmonAlarm.addListener(tick)synchronousawait loadConfig()dispatch onAlarmonMessage.addListener(h)too late for this wake
The dispatch point is the end of the synchronous pass — not the end of your initialisation.

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.

A registration structure that scalesFeature modules export handler maps; the entry module statically imports them and registers one dispatcher per event synchronously; handlers load configuration lazily.features/*.jsexport handler mapsStatic importresolved before bodyOne dispatcher per eventsync passat dispatch, the right handler runsHandler awaits config()memoisedReturns a promiseworker stays aliveEvicted when idlestate in storage
Adding a feature never touches the registration lines — which is what keeps them at the top level.
Registration mistakes and how they presentFive ways a listener ends up registered too late or too often, and the symptom each produces in development versus in the field.MistakeIn developmentIn the fieldTop-level await before addListenerWorksWaking event lostRegister inside a .thenWorksWaking event lostHandler from a dynamic importWorksWaking event lostConditional registrationWorksFeature intermittently offaddListener inside a handlerDuplicate callsDuplicate calls, reset on eviction
Every row works in development because DevTools keeps the worker awake — which is why the rule has to be enforced mechanically.

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.onInstalled is 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

  1. 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.
  2. 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.

  1. Run the CI grep and confirm it passes.
  2. 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.

Other MV3 Architecture & Extension Lifecycle Resources