Fix "Cannot use import statement outside a module" in a Chrome extension
6 min readModerok team
Why a Chrome extension service worker throws "Cannot use import statement outside a module", the type module manifest fix, and why importScripts then breaks.
If your Manifest V3 background script throws Uncaught SyntaxError: Cannot use import statement outside a module, the file is being loaded as a classic script and Chrome will not accept an import statement in one. The fix is one manifest key: add "type": "module" to the background object next to service_worker, then reload the extension. Chrome's own service worker documentation puts it plainly: "To use the import statement, add the "type" field to your manifest and specify "module"." Everything below is what that key changes, and what still fails after you set it.
The manifest change that fixes the error
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["storage"]
}
With no type key, the file is loaded as a classic script, and a classic worker script has no module scope, so the parser rejects import before a single line of your code runs. After editing manifest.json, click the reload icon on the extension's card in chrome://extensions. Editing the manifest is not picked up by a plain page refresh.
Why the error comes with no stack trace and no working console
The confusing part is not the message, it is where it shows up. When a service worker throws during its first evaluation, registration fails, so there is no registered worker for DevTools to attach to and nothing to click on the extension card. Chrome surfaces the failure on that card in chrome://extensions instead, behind the red "Errors" button, and reports from the chromium-extensions group show it as Service worker registration failed. Status code: 15, which is a script evaluation error.
Two practical notes in that view: old errors stay listed after you fix them, so hit "Clear all" before reloading, and if the message is too vague to place, comment out the top half of the file and bisect until the worker registers and gives you a console again.
What "type": "module" allows, and what it still refuses
Setting the key does not turn the service worker into a Node module resolver. Three limits catch people immediately; the first two are in Chrome's docs:
- Dynamic
import()is not supported. Chrome's documentation says so directly: "Note thatimport(), often called a dynamic import, is not supported." The error Chrome throws names the source of the rule:import() is disallowed on ServiceWorkerGlobalScope by the HTML specification. - Import assertions are not supported. Chrome's docs put it in one sentence: "Note that import assertions are not supported." Importing a JSON file directly is out. Read it with
fetch(chrome.runtime.getURL("data.json"))instead, or inline it as a JS module that exports an object. - Bare specifiers do not resolve.
import { thing } from "some-package"works in your editor because TypeScript resolves it againstnode_modules, and fails at runtime because the browser has nonode_modulesto look in. Unbundled imports must be relative paths to real files that ship inside the extension package, with the file extension included:
// background.js, loaded with "type": "module"
import { handleMessage } from "./lib/messages.js"; // resolves
import { z } from "zod"; // does not resolve
There is no escape hatch for pulling a missing module off a CDN at runtime: Manifest V3 bans remotely hosted code, and the docs are explicit that "your service worker must be part of the extension package."
"Module scripts don't support importScripts()"
The other way to load code into a worker is importScripts(), and the moment you set "type": "module" it stops working. Chrome throws Failed to execute 'importScripts' on 'WorkerGlobalScope': Module scripts don't support importScripts(). The two mechanisms are mutually exclusive: a module worker uses import, a classic worker uses importScripts(), and no configuration gives you both.
The classic route has its own timing rule that trips up MV3 code. Per the service worker specification, it may only be called during the synchronous top-level evaluation of the worker script, or inside an install handler. Since Chrome 71, calling it later, from a message handler or an alarm callback, throws at runtime unless that same URL was already imported during install. So this is fine:
// background.js, classic worker: no "type" key in the manifest
importScripts("./lib/vendor.js", "./lib/analytics.js");
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// vendor and analytics are already loaded here
});
And lazily importing on first message is not.
Bundle, and the error goes away for a different reason
Most extensions never hit this because their build step emits a single self-contained background file. webpack, Rollup, esbuild, Vite, and the extension frameworks built on a bundler (WXT on Vite, Plasmo on Parcel) inline your imports, so what ships has no import statement left to fail on.
That means the manifest key is really a function of what your bundler emits, not of what you wrote:
- Output format
iife, one file, no imports left: you do not need"type": "module". - Output format
esm, or any config that emits code-split chunks the entry point imports at runtime: you do need"type": "module", and every chunk must be inside the package. - Output format
cjs: avoid it for a background entry point. CommonJS output generally referencesmoduleandexports, and neither exists in a worker global, so you trade a syntax error for aReferenceErrorat the same point in startup.
Check that before you ship, because the failure mode is not a build error. The bundle looks fine, the extension loads, and the background context is simply dead: no listeners registered, no messages answered.
The same rule applies if you skip the bundler and drop a prebuilt library file into your extension folder. Moderok's standalone dist/moderok.min.js is a minified ES module, so it needs import and a module worker, not importScripts():
// background.js, with "type": "module" in the manifest
import { Moderok } from "./vendor/moderok.min.js";
Moderok.init({ appKey: "mk_your_app_key" });
Content scripts and extension pages follow different rules
The same error text in a content script means something else, because there is no manifest key to fix it. Content scripts declared in content_scripts are loaded as classic scripts, and content_scripts has no type field. Bundling into one file is the reliable answer; the common workaround otherwise is a dynamic import() of a module file listed in web_accessible_resources, which is available in a content script even though it is not in a service worker.
Extension pages are the easy case. A popup, options page, or side panel is a normal HTML document, so a module script tag works with no manifest involvement:
<script type="module" src="popup.js"></script>
Firefox runs an event page from background.scripts rather than a service worker, and MDN documents the same type property on the background key, defaulting to classic, deciding whether those scripts load as ES modules. If you ship a both-browsers manifest, see how a Chrome MV3 service worker becomes a Firefox event page for the rest of the differences.
Once imports work, keep registration at the top level
Modules do not change the hardest MV3 rule: the service worker is torn down and re-evaluated constantly, and every listener has to be registered during that initial evaluation. Registration pushed into a .then() that runs after evaluation finishes can miss the event that woke the worker. Import at the top, register at the top, do the slow work afterwards. If you are still fighting restarts, why your service worker keeps stopping covers the lifecycle in detail.
That is also why analytics for extensions has to be import-and-go rather than async setup. Moderok's init() registers its chrome.runtime.onInstalled listener synchronously before it touches storage, so a top-level import plus a top-level Moderok.init({ appKey }) is enough to record installs, updates, and daily active use across worker restarts. The SDK is 5.7 kB gzipped with zero dependencies, ships as ESM and CJS, and needs only the storage permission. See the Moderok manifest guide for the setup.