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.
Table of Contents
- Why each context differs
- Step-by-step: lean startup in every context
- 1. Split heavy features out of extension pages
- 2. Keep the service worker’s top level small
- 3. Offload heavy, rare work to a page that can split
- 4. Load extra code into content scripts on demand
- 5. Configure the bundler per context
- 6. Measure before and after
- 7. Watch for accidental imports in the worker bundle
- Common mistakes
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
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
- Load the popup with DevTools open and confirm the chart chunk loads only when its tab is opened.
- Grep the worker bundle for
import(— none. - Trigger the content-script feature and confirm the extra file is injected only on demand.
- Compare cold-start times in
chrome://serviceworker-internalsbefore 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.
Related
- Loading scripts with importScripts — the classic worker alternative.
- Speeding up popup first paint — measuring the page side.
- Using ES modules in an MV3 service worker — module worker setup.
- Build tooling and bundlers — the parent topic.