Manifest V3 notes: service worker races, dropped registrations, and other quiet failures
Jump to 8
- 1. Duplicate script ID from a startup reconcile that races itself
- 2. Content script match patterns cannot carry a port
- 3. createDocument resolves before the offscreen document can receive messages
- 4. Reloading the extension orphans every open tab's content script
- 5. Branded Chrome ignores --load-extension
- 6. Long synchronous work blocks whichever thread hosts it
- One more, which is about messaging rather than failure
- The pattern
Manifest V3 spreads an extension across a service worker that restarts on its own schedule, content scripts injected into pages that outlive it, and an offscreen document that exists only while something needs it. The seams between those are asynchronous, and some of them fail without surfacing an error anywhere a developer is likely to be looking.
Six cases from building a browser extension that reads a video page's subtitle track, synthesizes speech from it locally, and plays it in sync. The interesting part of each is the symptom, since the fixes are mostly obvious once the cause is visible.
1. Duplicate script ID from a startup reconcile that races itself
Chrome drops dynamically registered content scripts when an extension reloads, while chrome.storage survives. So a per-site opt-in still reads as enabled while nothing is registered, and pages never arm. Toggling the setting off and on repairs it, which is a good hint that the state is inconsistent rather than wrong.
Reconciling registrations at startup is the fix, and it introduces the second problem. A service worker has several startup triggers, and chrome.runtime.onStartup, chrome.runtime.onInstalled, and top-level module code can all run within milliseconds of each other. Two of them observe "not registered" and both call registerContentScripts, so the second throws:
Duplicate script IDThe reconcile then fails and the symptom is unchanged. The error appears only in the service worker console, which is not usually open during ordinary use.
Two changes were needed rather than one: serialize the reconcile calls through a promise chain so the second sees the first's result, and treat Duplicate script ID as recoverable by falling back to updateContentScripts. The general shape is worth keeping: startup work in an environment that restarts unpredictably wants to be serialized and idempotent, with "already exists" treated as an outcome rather than an error.
2. Content script match patterns cannot carry a port
// throws
chrome.scripting.registerContentScripts([{ matches: ['http://localhost:5173/*'], /* … */ }]);Match patterns have no port component. Register against http://localhost/*, which matches any port, and keep the full origin including the port in your own storage for the exact-match check the content script performs when deciding whether to arm. This is mostly a local-development and test-harness problem, which is where it is most annoying.
3. createDocument resolves before the offscreen document can receive messages
chrome.offscreen.createDocument() resolving means the document exists, not that its chrome.runtime.onMessage listener has been registered. A message sent immediately after can be dropped:
Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.Ping until the document answers, then send anything that matters. Worth doing even when it appears to work, because the race resolves differently under load and the result is a missing feature rather than an exception.
4. Reloading the extension orphans every open tab's content script
After a reload or update, content scripts in already-open tabs keep running but lose their connection to the extension, and every chrome.* call throws:
Extension context invalidated.There is no way to reconnect, so the script has to notice, undo whatever it was doing to the page, and say so. The message has to be built with DOM APIs only, since the extension APIs are already gone. Developers hit this constantly because reloading the extension is part of the loop; the difference is that a developer knows to reload the tab and a user does not.
5. Branded Chrome ignores --load-extension
As of roughly Chrome 137, branded builds silently ignore the flag. Automated tests then load no extension and report zero service workers, which reads as a startup crash rather than as nothing having been loaded. Chrome for Testing or Chromium loads it.
A related trap: do not take context.serviceWorkers()[0] when looking for your extension's worker. A real site registers its own service worker, and a persistent profile restores it, so index 0 can be the site's. Running extension code there gives:
ReferenceError: chrome is not definedSelect by chrome-extension:// scheme instead. Both of these produce a confident and wrong diagnosis, which is what makes them expensive relative to their difficulty.
6. Long synchronous work blocks whichever thread hosts it
GPU-backed inference runtimes have initialization phases that are synchronous and measured in tens of seconds. Running one inside an offscreen document blocked that document for 60 to 80 seconds on a cold start, stalling everything it was coordinating, with no error at all.
Moving model loading and inference into a dedicated Worker fixed it, at the cost that a worker has no access to chrome.*. Every extension asset URL has to be resolved with chrome.runtime.getURL on the other side and passed in, which is worth knowing before the model is wired up rather than after.
One more, which is about messaging rather than failure
Messaging between content script, service worker, and offscreen document serializes as JSON. No ArrayBuffer, no typed arrays. Generated audio crossed those boundaries as base64 WAV, at a copy and roughly 33% in size. Within a single context a Worker accepts transferables and the same audio moves with no copy, which is an argument for keeping bulk data on one side of a context boundary and sending references or summaries across it.
The pattern
Sorting the six by how they announce themselves: two produce no error at all (the ignored --load-extension flag, and the blocked thread), three produce an error somewhere easy to miss (a service worker console that is rarely open, an unchecked runtime.lastError, and the console of a tab whose content script has been orphaned), and one throws immediately where you would want it to. Two of those messages also point away from the cause rather than toward it.
That distribution is probably not unique to this platform. But an MV3 extension has more asynchronous context boundaries than a typical single-page application, and each boundary is somewhere an error can surface out of view of whoever is debugging.
The habits that helped, roughly in order of value: handshake rather than assume a listener exists; probe liveness rather than assume a context is still alive; serialize startup work and make it idempotent; and when the extension cannot recover, tell the user plainly, because the alternative is a feature that stops working with no explanation.