Loading Scripts with importScripts

Use importScripts in a classic MV3 service worker: when it is allowed, path resolution, load order, why it fails after startup, mixing with ES modules, and when to switch to a bundler or module worker instead.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The background code is split across several files — a messaging router, a storage layer, a vendored library — and in MV2 the manifest listed them all in background.scripts. MV3 accepts a single service_worker file. Without a bundler, the classic way to pull in the others is importScripts("router.js", "storage.js"), and it works — until someone calls it inside an event handler and gets “Failed to execute ‘importScripts’ on ‘WorkerGlobalScope’: Module scripts don’t support importScripts()”, or a different error saying it was called after installation. This guide covers when importScripts is the right tool, the rules it follows in extension workers, and when to move to modules or a bundle. It belongs to service worker fundamentals.

How importScripts works in an extension worker

importScripts(...urls) synchronously fetches and executes classic scripts in the worker’s global scope, in the order given, sharing globals with the calling script. It exists only in classic workers — those declared without "type": "module" — and throws in module workers. For service workers, the specification restricts it to the worker’s initial script evaluation (and, for previously imported URLs, the install phase); calls made later, such as inside an onMessage listener, fail because a service worker’s script set must be known up front. Paths resolve relative to the worker script’s location within the extension package. Imported files are not subject to network fetching — they load from the package — but a missing file or an exception in any imported script fails the whole worker start.

Classic worker start with importScriptsChrome evaluates sw.js, which calls importScripts for vendor, storage and router scripts in order; each runs in the shared global scope; then sw.js registers listeners; a later importScripts call inside a handler throws.sw.js evaluatedclassic workerimportScripts(...)vendor, storage, routerShared globalsself.Router, self.Storethen, still synchronouslyaddListener(...)top levelEvents dispatchedafter first passimportScripts in handlerthrows
Import everything during the first evaluation — never later.

Step-by-step: using it correctly

1. Declare a classic worker

1{ "background": { "service_worker": "sw.js" } }        // no "type": "module"

Execution context: the manifest. Omitting type makes the worker classic, where importScripts exists and import statements are a syntax error. Choose one model for the whole worker — mixing produces registration failures, as described in fixing “service worker registration failed”.

2. Import everything at the top, in dependency order

1// sw.js
2importScripts(
3  "vendor/idb-keyval.js",     // defines self.idbKeyval
4  "lib/storage.js",           // uses idbKeyval, defines self.Store
5  "lib/router.js",            // uses Store, defines self.Router
6);
7
8chrome.runtime.onMessage.addListener(self.Router.handle);
9chrome.alarms.onAlarm.addListener(self.Router.onAlarm);

Execution context: the service worker’s first synchronous evaluation. Each imported file runs to completion before the next starts, so order expresses dependencies. Imported scripts communicate through globals on self; keep them namespaced (one global per file) to avoid collisions. Listener registration still happens at the top level of sw.js, after the imports, in the same synchronous pass.

Splitting worker code: three approachesimportScripts in a classic worker, static ES module imports in a module worker, and a single bundled file compared on tooling, scoping, lazy loading and error behaviour.ApproachNeeds toolingScopingLazy loadingimportScripts (classic)NoShared globalsNoES modules (type: module)NoModule scopeNo (no import())Bundled single fileYesModule scopeNo
importScripts is fine for small, tool-free projects; bundling scales best.

3. Do not call it lazily

1// WRONG — throws after initial evaluation
2chrome.runtime.onMessage.addListener((msg) => {
3  if (msg.type === "pdf") { importScripts("lib/pdf.js"); parsePdf(msg.data); }
4});
5
6// RIGHT — import at the top even if rarely used, or move the work to an offscreen document
7importScripts("lib/pdf.js");

Execution context: the service worker. There is no lazy loading in service workers — neither importScripts after startup nor dynamic import(). If a rarely used library is large enough to slow every cold start, move the work that needs it into an offscreen document or extension page, which can load scripts on demand, as covered in code splitting and dynamic imports in MV3.

A late importScripts callThe worker starts and evaluates sw.js; later a message arrives and the handler calls importScripts; the worker throws because its script set is fixed after the initial evaluation.ChromeWorkerevaluate sw.jsimportScripts(a, b) ✓onMessage(pdf)importScripts('pdf.js') ✗ t…
The script set is sealed after the first evaluation.

4. Make imported files worker-safe

1// lib/storage.js — classic script, no window, no document, one namespaced global
2(function (global) {
3  const Store = {
4    async get(key) { return (await chrome.storage.local.get(key))[key]; },
5    async set(key, value) { await chrome.storage.local.set({ [key]: value }); },
6  };
7  global.Store = Store;
8})(globalThis);

Execution context: a classic script imported by the worker. An exception in any imported file aborts the worker’s start, so imported code must not touch window or document and must not throw at load time. Wrapping each file in an IIFE that exposes one global keeps internals private, the classic-script equivalent of a module.

5. Handle third-party libraries carefully

Libraries published as UMD or IIFE bundles usually work with importScripts, exposing a global. Libraries published only as ES modules do not — they contain export statements, a syntax error in a classic script. For those, switch the worker to "type": "module", or bundle. Check that a library does not assume a window (common in UMD builds that test typeof window).

6. Know when to migrate to modules or a bundle

importScripts suits small extensions with a handful of hand-written files and no build step. Once you have npm dependencies, TypeScript, or more than a few files, a module worker (import/export with static imports) gives proper scoping, and a bundler gives tree-shaking and a single file with predictable start-up. The migration is mechanical: replace importScripts calls with import statements, replace globals with exports, and set "type": "module". See using ES modules in an MV3 service worker.

7. Test start-up after every change

Any error in any imported file prevents the worker from starting. After changing imports, reload the extension, check the Errors view on chrome://extensions, and trigger an event from a cold start. A CI step that loads the worker’s files in order in a worker-like environment catches load-time exceptions before release.

Common mistakes

  • importScripts in a module worker. It throws; use import.
  • Calling it inside handlers. Only allowed during initial evaluation.
  • Wrong order. A file using a global defined by a later file fails.
  • Imported files touching window. The whole worker fails to start.
  • ES-module-only libraries. export is a syntax error in classic scripts.

Keep imported files small and side-effect free

Every imported file runs on every cold start, so top-level work in those files — building lookup tables, reading storage, parsing large constants — adds directly to wake-up latency. Define functions and constants at load time and defer anything expensive until a handler needs it. A worker whose imports finish in a few milliseconds keeps every event, from alarms to popup messages, feeling instant.

Cross-browser variation

  • Chrome / Edge: importScripts in classic extension service workers during initial evaluation.
  • Firefox: the MV3 background is typically an event page declared with background.scripts, which loads files in order — the manifest replaces importScripts. In a Firefox service worker background, the same rules as Chrome apply.
  • Safari: classic workers support importScripts; module workers require recent versions.

Verification

  1. Reload the extension and confirm no errors; the worker starts.
  2. Stop the worker and trigger an event; confirm all imported globals exist.
  3. Temporarily move an importScripts call into a handler and confirm it throws — then move it back.
  4. Remove an imported file from the build and confirm registration fails with a clear error, proving the test catches missing files.

FAQ

Can importScripts load files from a URL?

Only from the extension package. Loading remote scripts is remote code and blocked.

Does importScripts slow the cold start?

Each file is read and evaluated on every start. A few small files are negligible; many large ones add up — bundling helps.

Can I conditionally import based on browser?

Yes, with a synchronous condition at the top level, before listener registration. It must still happen during initial evaluation.

Is importScripts deprecated for extensions?

No. It remains supported for classic workers. Module workers are simply the more modern option, and bundlers make the choice largely invisible.

How do I share code between the worker and extension pages with importScripts?

Write the shared file as a classic script that attaches one global, import it in the worker with importScripts, and include it in pages with a <script> tag before the page’s own script. A bundler removes this duplication.

Other MV3 Architecture & Extension Lifecycle Resources