Messages Sent While the Worker Is Starting

Diagnose and fix the MV3 cold-start race where a popup or content script message arrives before the service worker has registered its listeners — queueing, readiness and retry.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

The bug reproduces about one time in twenty and never when you are watching: the popup opens, sends a message, and gets nothing back. Reopen it and everything works. What happened is that the message arrived while the service worker was still evaluating its own module graph, and the listener that would have answered had not been registered yet. This guide is part of message passing architecture.

The race, precisely

A message to the extension wakes the worker. The browser then evaluates the worker script and dispatches the queued event once the first synchronous pass completes. Listeners registered during that pass receive the event. Listeners registered later — after an await, inside a .then, or from a dynamically imported module — do not, because dispatch has already happened.

Where the listener registration has to landA timeline of a cold start: the worker script is evaluated, the queued message is dispatched at the end of the synchronous pass, and any later registration misses it.worker startfirst await resolvesModule graph loadsstatic importsTop-level sync passaddListener calls run hereDispatch …message de…Microtasks and awaitstoo late to registerlisteners must exist by hereasync init finishes
Everything after the dashed marker is too late for the message that woke the worker.

Step-by-step

1. Register synchronously, initialise lazily

The fix is to separate registering from being ready. Register at the top level; make the handler wait for initialisation.

 1// service-worker.js
 2import { handle } from "./rpc-server.js";
 3
 4// Started immediately, awaited by handlers — never awaited at the top level.
 5const ready = (async () => {
 6  const { settings } = await chrome.storage.local.get("settings");
 7  return { settings: { ...DEFAULTS, ...settings } };
 8})();
 9
10handle("settings:read", async () => (await ready).settings);

Execution context: the top level of the service worker. ready is a promise created synchronously, so the handle calls below it run in the same pass. The first message arrives, its handler awaits ready, and the worker stays alive because the returned promise is pending.

2. Never await before your listeners

This is the single most common cause, and it is easy to introduce by accident — a top-level await in an ES module worker is legal and moves everything after it past the dispatch point.

 1// Broken: the await splits the synchronous pass.
 2const config = await loadConfig();          // ← dispatch happens around here
 3chrome.runtime.onMessage.addListener(handler);   // too late for the waking message
 4
 5// Correct: register first, resolve config inside the handler.
 6const configPromise = loadConfig();
 7chrome.runtime.onMessage.addListener((msg, sender, respond) => {
 8  configPromise.then((config) => respond(handle(msg, config)));
 9  return true;
10});

Execution context: the service worker module body. Chrome, Firefox and Safari all behave this way; Firefox’s event page is more forgiving in timing but the rule is identical.

3. Avoid dynamic imports on the registration path

await import("./handlers.js") has the same effect as any other await. Import handler modules statically, even if they are large — the worker is re-evaluated on every cold start either way, so a lazy import saves nothing and costs correctness.

1// Broken
2chrome.runtime.onInstalled.addListener(async () => {
3  const { registerAll } = await import("./handlers.js");
4  registerAll();
5});
6
7// Correct
8import { registerAll } from "./handlers.js";
9registerAll();

Execution context: the service worker top level. Static imports are resolved before the module body executes, so registerAll() runs inside the synchronous pass.

4. Retry once on the sender’s side

Even with perfect registration, a message can be lost if the worker is being torn down at the instant it arrives. A single retry on the caller’s side closes that window.

 1export async function sendResilient(payload) {
 2  for (let attempt = 0; attempt < 2; attempt++) {
 3    try {
 4      const reply = await chrome.runtime.sendMessage(payload);
 5      if (reply !== undefined) return reply;
 6    } catch (err) {
 7      if (!/Receiving end does not exist|message port closed/i.test(err.message)) throw err;
 8    }
 9    await new Promise((r) => setTimeout(r, 120));   // let the worker finish starting
10  }
11  throw new Error(`no response for ${payload.type}`);
12}

Execution context: the popup, options page or a content script — never the worker itself, where the delay risks the idle timer. Only retry idempotent requests; a retried write must be safe to apply twice.

5. Wake the worker before you need it

A popup can send a cheap ping as its first statement, so the worker is warm by the time the real request goes out.

1// popup.js — fire and forget, before rendering
2chrome.runtime.sendMessage({ type: "ping" }).catch(() => {});
3
4document.addEventListener("DOMContentLoaded", async () => {
5  const settings = await sendResilient({ type: "settings:read" });
6  render(settings);
7});

Execution context: the popup document. The ping’s rejection is ignored on purpose: if it fails, the worker is starting, which is exactly the outcome the ping was meant to cause.

The two halves of the fixRegistration happens synchronously on the worker side while readiness is awaited inside handlers; the sender adds one retry and an optional warm-up ping.Static imports onlyno dynamic importaddListener at top levelbefore any awaitawait readiness insidehandler-scopedand on the sending sideWarm-up pingfire and forgetOne retryidempotent reads onlySurface the failurenever a silent undefined
Neither half is sufficient alone — synchronous registration removes the common case, the retry covers the teardown race.

Auditing a worker for late registration

Reviewing a worker for this bug by eye stops scaling at about two hundred lines, and it fails entirely once handlers are spread across modules. Two mechanical checks catch nearly everything.

The first is a runtime assertion. Record the moment the synchronous pass ends, and have any later addListener complain loudly:

1let syncPassOver = false;
2queueMicrotask(() => { syncPassOver = true; });
3
4const origAdd = chrome.runtime.onMessage.addListener.bind(chrome.runtime.onMessage);
5chrome.runtime.onMessage.addListener = (fn) => {
6  if (syncPassOver) console.error("[sw] LATE onMessage registration", new Error().stack);
7  return origAdd(fn);
8};

Execution context: the very top of the service worker module, before any other import runs its side effects. queueMicrotask fires after the synchronous pass and before the first macrotask, which is a close enough proxy for the dispatch boundary to be useful. Ship this in development builds only — monkey-patching a chrome.* method in a release build is the kind of thing a reviewer will ask about.

The second is a static check in CI. A worker entry point should contain no top-level await and no dynamic import() on the registration path:

1# fail the build if the worker module top level awaits anything
2grep -nE '^\s*(await|const .*=\s*await)' src/service-worker.js && exit 1
3grep -n 'await import(' src/**/*.js && exit 1

Execution context: the shell, in CI. Crude, and it will occasionally flag a false positive inside a function body — worth tightening with a real parser once it has caught its first real bug, which it usually does within a month.

Both checks answer the same question from different directions: is everything registered before the browser dispatches? That question is also worth asking of onInstalled, onStartup, onAlarm and onClicked, all of which are dispatched on the same schedule and all of which fail the same silent way.

Events lost to late registrationFive worker events, what is lost when their listener is registered after the synchronous pass, and how visible the failure is.EventLost when lateVisibilityruntime.onMessageThe waking messageSender sees undefinedruntime.onInstalledFirst-run setupInvisible until a bug reportruntime.onStartupSchedule re-anchoringAlarms quietly missingalarms.onAlarmThat tickA skipped syncaction.onClickedThe clickButton does nothing
The install event is the worst of these — it fires once per install and there is no second chance.

Cross-browser variation

  • Chrome / Edge: the strictest of the three. Registration must complete in the first synchronous pass, and Chrome logs nothing when a message is dropped — the sender simply sees undefined.
  • Firefox: the background event page is kept alive longer and starts faster from a warm profile, so the race is rarer. The same code is required for correctness; do not let Firefox’s tolerance convince you the bug is fixed.
  • Safari: background context start-up is the slowest, and Safari is where a warm-up ping makes the most visible difference. Safari also delivers onMessage slightly later relative to onStartup than Chrome does.
  • All three: chrome.runtime.onInstalled and onStartup are subject to exactly the same rule. An await before those registrations loses the install event on a fresh install, which is the hardest version of this bug to reproduce.

Verification

  1. Force the race deliberately. In chrome://extensions, click Stop on the service worker, then immediately open the popup and watch the network of logs.
  2. Confirm registration order with a marker at the top and bottom of the worker module:
1console.log("[sw] sync pass start");
2// … all addListener calls …
3console.log("[sw] sync pass end");

Execution context: the service worker console. On a cold start woken by a message, both lines must appear before the handler’s own log line. If the handler logs between them, something is registering late.

  1. Repeat the stop-and-open cycle twenty times. A correctly wired worker answers every time; the original bug fails roughly one in twenty.
  2. Check the errors page at chrome://extensions → Errors for “Could not establish connection” entries accumulated during the run.

FAQ

Does returning a promise from the top level keep the worker alive?

No. Only a promise returned from an event listener extends the worker’s life. A pending top-level promise is not tracked, and the worker can be evicted with it unresolved.

Can I just make the popup wait a bit before sending?

A fixed delay trades a rare failure for a guaranteed slower popup, and it still fails on a slow machine. Register correctly and retry once — that is both faster and more reliable.

Why does it always work when DevTools is open?

An attached inspector keeps the worker alive, so it is never cold. Reproduce with DevTools closed, then open it afterwards to read the logs.

Other Core APIs & Cross-Browser Data Management Resources