Code Splitting and Dynamic Imports in MV3

Where code splitting works in an MV3 extension: why service workers reject import(), lazy loading in popups and options pages, loading modules into content scripts via getURL, and keeping startup fast.

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

The popup takes 400 milliseconds to show anything because it loads a charting library it needs only on one tab. The service worker’s cold start is slow because it bundles a PDF parser used once a week. Code splitting and lazy loading fix both in web apps. In an extension, they work in some contexts and are forbidden in others: a dynamic import() in the service worker throws, content scripts cannot use import at all, and extension pages behave like ordinary pages. This guide maps the rules and shows how to keep each context’s startup lean anyway. It belongs to build tooling and bundlers.

Why each context differs

The rules come from the web platform, not from extensions. Service workers — including extension service workers — must have their full module graph known at registration time, so the HTML specification forbids import() inside them; it throws TypeError: import() is disallowed on ServiceWorkerGlobalScope. Static import statements in a module worker are fine. Content scripts are injected as classic scripts, so import statements are a syntax error; a dynamic import() of a URL does work, but only for files the page is allowed to load — which means web-accessible resources. Extension pages are normal documents on the extension’s origin: <script type="module">, static imports, dynamic imports and bundler-generated chunks all work. Splitting therefore pays off mainly in pages, and in the worker the lever is not lazy loading but keeping the top-level graph small.

Lazy loading options by contextWhether dynamic import, bundler chunks, and alternative techniques are available in the service worker, content scripts and extension pages.Contextimport()Bundler chunksAlternativeService workerThrowsStatic onlySmaller top-level graphContent scriptWeb-accessible URLs onlyNoInject more files on dema…Popup / options / side panelYesYes—Offscreen documentYesYesHost heavy work here
Split freely in pages; in the worker, shrink the graph instead.

Step-by-step: lean startup in every context

1. Split heavy features out of extension pages

1// popup.js — the stats tab loads its chart library only when opened
2tabs.addEventListener("change", async (e) => {
3  if (e.target.value !== "stats") return;
4  const { renderChart } = await import("./stats-chart.js");      // separate chunk
5  renderChart(document.querySelector("#chart"), await loadStats());
6});

Execution context: the popup, an ordinary extension page. The bundler emits stats-chart.js and its dependencies as a chunk loaded on demand from the extension package — no network, no CSP issue, since the chunk is on the extension’s own origin. The popup’s first paint now includes only the default tab’s code. The same applies to options pages and side panels. Vite, esbuild (with splitting: true) and webpack all produce such chunks.

2. Keep the service worker’s top level small

1// sw.js — register listeners first; keep heavy modules out of the startup graph
2import { handleMessage } from "./messages.js";          // small router
3import { onAlarm } from "./jobs.js";
4
5chrome.runtime.onMessage.addListener(handleMessage);
6chrome.alarms.onAlarm.addListener(onAlarm);
1// jobs.js — heavy work behind a function boundary, but still statically imported
2import { parsePdf } from "./pdf/parse.js";               // bundled in, evaluated at startup
3export async function onAlarm(a) { if (a.name === "pdf-index") await indexPdfs(parsePdf); }

Execution context: the service worker. Every statically imported module is downloaded, parsed and evaluated on each cold start, even if the event that woke the worker never touches it. You cannot lazy-load with import(), but you can measure which modules dominate startup and move rarely used heavy work elsewhere — an offscreen document or an extension page — or trim dependencies. See reducing service worker cold start latency.

Moving rarely used heavy code out of the workerThe worker keeps a small static graph; when a rare heavy task arrives, it creates an offscreen document that dynamically imports the heavy module, does the work, returns the result and closes.Service workersmall static graphRare task arrivese.g. parse a PDFcreateDocumentoffscreen pageinside the offscreen documentimport('./pdf.js')allowed in pagesDo the workDOM + workers okReply + closememory released
The worker stays fast to start; the heavy code loads only when needed, in a context that allows import().

3. Offload heavy, rare work to a page that can split

 1// sw.js
 2export async function parsePdfInOffscreen(bytes) {
 3  await ensureOffscreen({ url: "offscreen.html", reasons: ["DOM_PARSER"], justification: "Parse PDF text" });
 4  return chrome.runtime.sendMessage({ target: "offscreen", type: "parse-pdf", bytes });
 5}
 6
 7// offscreen.js
 8chrome.runtime.onMessage.addListener((m, _s, reply) => {
 9  if (m.target !== "offscreen" || m.type !== "parse-pdf") return;
10  import("./pdf/parse.js").then(({ parsePdf }) => parsePdf(m.bytes)).then(reply);
11  return true;
12});

Execution context: the worker delegates; the offscreen document — an extension page — can use import(). Choose an offscreen reason that genuinely matches the work; for pure computation, a dedicated Worker started from an extension page may be the better home, as described in running WASM and heavy compute outside the service worker.

4. Load extra code into content scripts on demand

1// content.js (IIFE) — small bootstrap; heavy UI only when the user asks for it
2document.addEventListener("keydown", async (e) => {
3  if (!(e.altKey && e.key === "r")) return;
4  const { openReader } = await import(chrome.runtime.getURL("reader/reader.js"));
5  openReader(document);
6});
1{ "web_accessible_resources": [{ "resources": ["reader/*.js"], "matches": ["https://*.example.com/*"], "use_dynamic_url": true }] }

Execution context: a content script. import() of an extension URL works from content scripts if the module is web accessible to the page’s origin. The imported module runs in the content script’s isolated world. The cost is exposure: web-accessible files can be fetched by matching pages, so expose only what is needed, scope matches narrowly, and use use_dynamic_url. The alternative with no exposure is to have the worker inject an additional classic script file with chrome.scripting.executeScript({ files }) when the feature is triggered.

On-demand reader in a content scriptA small content script bootstrap waits for a shortcut; on trigger it asks the worker, which injects the larger reader script into the tab with executeScript; no web-accessible exposure is needed.Content bootstrapService workerTabAlt+R pressed{type:'reader:load'}executeScript({files:['reader.js']})reader.js runs …
Injecting a second file on demand avoids making modules web accessible.

5. Configure the bundler per context

1// vite.config.js excerpt (or equivalent in your tool)
2build: {
3  rollupOptions: {
4    input: { popup: "src/popup/index.html", options: "src/options/index.html" },  // pages: split freely
5    output: { chunkFileNames: "chunks/[name]-[hash].js" },
6  },
7},
8// background and content scripts built separately with inlineDynamicImports: true

Execution context: build configuration. Pages share chunks between them; the worker and content scripts are built as single files (Rollup’s inlineDynamicImports, esbuild without splitting) so the bundler never emits an import() the runtime would reject. Frameworks such as WXT and CRXJS apply these rules automatically.

6. Measure before and after

Splitting adds complexity; do it where measurement shows a benefit. Record popup time-to-first-paint and worker cold-start time before changes, then after. A popup whose main chunk drops from 300 KB to 60 KB typically paints noticeably faster on slow machines; a worker that loses a large dependency starts faster on every wake-up, which matters far more because it happens hundreds of times a day.

7. Watch for accidental imports in the worker bundle

1grep -n "import(" dist/chrome/sw.js && echo "dynamic import found in worker bundle" && exit 1

Execution context: a CI step after the build. A dependency update can introduce a dynamic import into a library the worker uses — for example, a library that lazy-loads locale data. The bundler may leave it as import() and the worker will throw only when that code path runs. A grep in CI catches it before release.

Common mistakes

  • import() in the worker. It throws at runtime; bundle statically.
  • ES module content scripts. Syntax error; use IIFE with optional on-demand injection.
  • Making everything web accessible to enable import() in content scripts. It exposes your code to pages.
  • Splitting tiny pages. The extra request costs more than it saves.
  • Not checking dependencies. Libraries can add dynamic imports in minor versions.

Cross-browser variation

  • Chrome / Edge: import() disallowed in the service worker; allowed in pages and, for web-accessible URLs, in content scripts.
  • Firefox: the event-page background is a document, where import() works — code relying on it will break in Chrome. Keep the worker rule everywhere.
  • Safari: service worker rules match Chrome’s; dynamic import from content scripts follows the same web-accessible requirement.

Verification

  1. Load the popup with DevTools open and confirm the chart chunk loads only when its tab is opened.
  2. Grep the worker bundle for import( — none.
  3. Trigger the content-script feature and confirm the extra file is injected only on demand.
  4. Compare cold-start times in chrome://serviceworker-internals before and after trimming the worker graph.

FAQ

Can the worker use importScripts instead?

Classic (non-module) service workers can call importScripts during initial evaluation only. It is not a lazy-loading mechanism either.

Do chunks need to be listed in the manifest?

No. Pages load them by relative URL from the extension origin.

Is there a size where splitting stops mattering?

For pages under about 50 KB of JavaScript, splitting rarely changes perceived speed.

Other MV3 Architecture & Extension Lifecycle Resources