Using ES Modules in an MV3 Service Worker
Declare a module service worker in Manifest V3 — type: module, static imports only, no importScripts, how bundlers emit it, and what changes for Firefox and Safari.
Table of Contents
A Manifest V3 service worker can be a classic script or an ES module, and the choice changes which loading mechanisms are available. A module worker gets real import statements and loses importScripts; a classic worker keeps importScripts and cannot use import. Mixing the two conventions — or trusting a bundler default without checking — produces a worker that fails to register with an error message pointing at the wrong line. This guide is part of service worker fundamentals.
Classic versus module workers
Step-by-step
1. Declare the worker as a module
1{
2 "manifest_version": 3,
3 "background": {
4 "service_worker": "service-worker.js",
5 "type": "module"
6 }
7}
Execution context: parsed at install. Without "type": "module", the first import statement is a syntax error and the worker fails to register — visible on chrome://extensions as “Service worker registration failed” with the line of the import.
2. Use static imports with relative paths and extensions
1// service-worker.js
2import { onAlarm } from "./scheduler.js";
3import { handle } from "./rpc/server.js";
4import { DEFAULTS } from "../shared/settings.js";
Execution context: the service worker. There is no module resolution at runtime — no node_modules lookup, no import maps, no extensionless paths. Every specifier must be a relative or absolute path to a file that exists in the package, and a bare specifier like "lodash-es" fails. Bundling resolves this; unbundled code must use real paths.
3. Do not reach for importScripts
1// Throws in a module worker:
2importScripts("vendor/polyfill.js");
3// TypeError: Module scripts don't support importScripts().
Execution context: the service worker. Convert legacy vendor files to modules — usually by wrapping them in an export — or bundle them into the worker. The webextension-polyfill ships a module build for exactly this reason, as described in using the webextension-polyfill in MV3.
4. Keep dynamic import off the event path
import() works in a module worker and resolves asynchronously — which means anything it provides arrives after the synchronous pass, after the waking event has already been dispatched.
1// Acceptable: lazily loading a heavy library inside a handler that needs it.
2chrome.runtime.onMessage.addListener((msg, _s, respond) => {
3 if (msg.type !== "pdf:render") return false;
4 import("./vendor/pdf.js").then(({ render }) => render(msg.url)).then(respond);
5 return true;
6});
7
8// Broken: obtaining a handler through dynamic import.
9const { onAlarm } = await import("./scheduler.js");
10chrome.alarms.onAlarm.addListener(onAlarm); // registered after dispatch
Execution context: the service worker. The first pattern is a legitimate optimisation — the listener is registered synchronously and the library is loaded only when needed. The second is the late-registration bug described in registering listeners at the top level.
5. Check what the bundler actually emitted
Bundlers default to formats designed for pages. Confirm the worker entry is an ES module with no DOM references and that its imports point at files in the package.
1head -5 dist/service-worker.js
2grep -c "^import " dist/service-worker.js
3grep -n "document\.\|window\." dist/service-worker.js || echo "no DOM references"
Execution context: your shell, against the build output. An import line referencing a chunk under assets/ is normal for Vite; a document. reference means a shared chunk that touches the DOM was pulled into the worker graph and will throw on start. The per-bundler configuration is covered in bundling an MV3 extension with Vite.
What a module graph costs at cold start
A module worker evaluates its whole static import graph on every cold start, before any listener runs. The browser caches parsed modules between starts, so the cost is mostly evaluation rather than parsing — but evaluation includes every top-level statement in every imported module, and a large dependency with heavy module-scope setup pays that cost on every wake.
Two rules follow. Keep module-scope code in worker dependencies to declarations — no work at import time. And load genuinely heavy, rarely used libraries through import() inside the handler that uses them, never at the top level. That is the one place dynamic import belongs in a worker, and it is how the cold-start numbers in reducing service worker cold start latency are achieved.
Migrating a classic worker to modules
Many MV3 extensions started as a direct port of an MV2 background page: one classic worker, a stack of importScripts calls at the top, and globals shared between the imported files. Converting that to a module worker is mechanical, but the order of operations matters because the two forms cannot be mixed in one file.
Start by making the dependency graph explicit. In a classic worker, importScripts("a.js", "b.js") evaluates both into one global scope, and b.js can freely read anything a.js declared at top level. Modules have no shared global scope for declarations — each file must export what others use.
1// Before — classic, implicit globals
2importScripts("storage.js", "sync.js"); // sync.js reads STORAGE_KEYS from storage.js
3chrome.alarms.onAlarm.addListener(runSync);
4
5// After — module, explicit edges
6import { STORAGE_KEYS } from "./storage.js"; // (used by sync.js via its own import)
7import { runSync } from "./sync.js";
8chrome.alarms.onAlarm.addListener(runSync);
Execution context: the service worker entry, before and after. Converting one file at a time is not possible because importScripts and import cannot coexist in one worker; the practical approach is to add export/import to every file on a branch, flip "type": "module" in the manifest in the same commit, and test the whole graph at once.
Two behaviours change underneath you. Modules are always in strict mode, so any sloppy-mode code — assignments to undeclared variables, with, duplicate parameter names — becomes an error. And module top-level this is undefined rather than the global object, which breaks older libraries that detect their environment via this. Both surface immediately at load, which is the one mercy of this migration: nothing about it fails silently.
Cross-browser variation
- Chrome / Edge: module service workers supported since MV3’s introduction.
importScriptsthrows in module workers; dynamicimport()is supported. - Firefox: MV3 backgrounds are event pages declared with
background.scripts, and"type": "module"there is supported from Firefox 121. Earlier Firefox versions need a bundled single-file background — a reason to generate the manifest per target. - Safari: supports
"type": "module"for its service-worker background from Safari 16.4. Dynamic import in the worker has been less reliable across versions; bundle heavy libraries for Safari rather than lazy-loading them. - All three: extension workers have no access to
node_modulesresolution. What ships in the package is what can be imported.
Verification
- Confirm the worker registered as a module:
1chrome.runtime.getManifest().background;
2// { service_worker: "service-worker.js", type: "module" }
Execution context: the service worker console. If the worker failed to start, chrome://extensions shows the error with the file and line — a syntax error on an import line means type is missing.
- Open the worker’s DevTools Sources panel and confirm each module appears as a separate file with the expected path.
- Time a cold start with and without the heavy library imported statically, using
performance.now()at the top and bottom of the entry module. - Load the Firefox build with
web-ext runand confirm the same module graph loads there.
FAQ
Should I bundle the worker into a single file anyway?
For Chrome it makes little difference to performance, since parsed modules are cached. For portability to older Firefox and Safari versions a single bundled file is safer. Many projects ship modules to Chrome and a bundled file elsewhere.
Can a module worker import JSON?
With an import attribute — import data from "./data.json" with { type: "json" } — in Chrome and recent Firefox. For portability, bundle JSON into a .js module at build time instead.
Why does my worker fail with “Cannot use import statement outside a module”?
Because the manifest lacks "type": "module", or because a bundler emitted a classic script that still contains an import from an unprocessed dependency.
Related
- Registering listeners at the top level — why dynamic imports cannot supply handlers.
- Bundling an MV3 extension with Vite — emitting a correct module worker.
- Reducing service worker cold start latency — the performance side of the module graph.
- Service worker fundamentals — the parent guide.