Keeping the Extension Bundle Small
Shrink an MV3 extension's JavaScript where size actually costs — content scripts parsed on every page, the worker evaluated on every cold start, the popup on every open — with budgets enforced in CI.
Table of Contents
Extension size is not one number. A megabyte of JavaScript that only the options page loads costs almost nothing; the same megabyte in a content script matching every URL is parsed into every page the user opens, all day. The service worker’s bundle is evaluated on every cold start, and the popup’s on every click. Knowing which bytes are paid where — and how often — tells you where cutting size is worth the effort and where it is not. This guide is part of performance profiling and optimisation.
Where bytes cost the most
Step-by-step
1. Measure each entry point separately
1npx vite build --mode production
2find dist/chrome -name '*.js' -exec sh -c 'printf "%8d %s\n" $(gzip -c "$1" | wc -c) "$1"' _ {} \; | sort -rn | head
Execution context: your shell after a production build. Gzipped size is a reasonable proxy for transfer but not for parse cost — parse time scales with the uncompressed size, so look at both. Group the output by entry point, not by chunk, because what matters is what each context loads.
2. See what is inside each bundle
1npx vite-bundle-visualizer --output dist/stats.html
Execution context: your shell. A treemap per entry point shows the one dependency that accounts for half the content script — a date library, a full lodash import, a markdown parser pulled in by a shared utility. The fix is almost always to remove or replace that one thing, not to micro-optimise your own code.
3. Keep the content script tiny and load the rest on demand
1// content/main.js — the always-injected part: a few KB, no dependencies
2const target = document.querySelector("article");
3if (target) {
4 const { enhance } = await import(chrome.runtime.getURL("content/enhance.js"));
5 enhance(target);
6}
Execution context: the content script’s isolated world. Pages without an <article> pay only for parsing the few lines above; pages that need the feature load the heavy module once. The lazy module must be listed in web_accessible_resources, as described in declarative vs programmatic content script registration.
4. Keep heavy libraries off the worker’s cold-start path
The worker’s static import graph is evaluated on every wake. A PDF library or a large parser that only one handler uses should be imported inside that handler.
1chrome.runtime.onMessage.addListener((msg, _s, respond) => {
2 if (msg.type !== "export:pdf") return false;
3 import("./vendor/pdf-lib.js").then(({ PDFDocument }) => buildPdf(PDFDocument, msg)).then(respond);
4 return true;
5});
Execution context: the service worker. The listener is registered synchronously; only the heavy module is deferred. This is the one correct place for dynamic import in a worker, as discussed in using ES modules in an MV3 service worker.
5. Import precisely from utility libraries
1// 70 KB: the whole library
2import _ from "lodash";
3const debounced = _.debounce(save, 300);
4
5// 1 KB: one function
6import debounce from "lodash-es/debounce.js";
7
8// 0 KB: the platform already has it
9const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
Execution context: any extension module. Extensions target a known, current browser — there is no need for polyfills or broad-compatibility libraries. Intl, structuredClone, URL, AbortController and modern array methods replace a surprising number of dependencies outright.
6. Enforce budgets in CI
1// build/check-budgets.mjs
2import { statSync } from "node:fs";
3const BUDGETS = {
4 "dist/chrome/content/main.js": 12_000,
5 "dist/chrome/service-worker.js": 60_000,
6 "dist/chrome/popup.js": 80_000,
7};
8let failed = false;
9for (const [file, max] of Object.entries(BUDGETS)) {
10 const size = statSync(file).size;
11 if (size > max) { console.error(`${file}: ${size} > ${max}`); failed = true; }
12 else console.log(`${file}: ${size} / ${max}`);
13}
14process.exit(failed ? 1 : 0);
Execution context: Node, in CI after the build. Budgets per entry point — tightest for the content script — turn “the bundle got bigger” from something noticed months later into a failed pull request with the file name in the message. The pipeline placement is in building a GitHub Actions pipeline for extensions.
Beyond JavaScript
Most extension weight is script, but three other things are worth a glance.
Icons and images. Ship the sizes the manifest actually uses — 16, 32, 48, 128 — as optimised PNGs or a single SVG for in-page UI. A 512-pixel PNG scaled down in CSS is a common and pointless few hundred kilobytes.
Fonts. A custom web font in the popup is a network-free but not parse-free cost, and it delays text rendering. The system font stack is usually indistinguishable and free.
Locales. _locales files are small individually, but an extension shipping forty languages with thousands of strings each adds up. They do not affect runtime cost — the browser loads only the active locale — but they do affect package size and review time.
The overall package size matters mostly for review and update download, not for runtime. The Chrome Web Store handles packages of many megabytes; review for very large packages tends to be slower, and every user downloads every update. Keeping the package tidy is a courtesy rather than a performance necessity — the runtime cost lives almost entirely in the entry points above.
Cross-browser variation
- Chrome / Edge: V8 caches compiled code for extension scripts across loads, which softens repeated parse cost but does not remove evaluation cost. Content scripts are still compiled per page.
- Firefox: similar caching behaviour. AMO reviewers read the bundle, so a smaller, less minified bundle also reviews faster.
- Safari: extension resources live inside the app bundle; very large extensions increase app download size for App Store users.
- All three: the per-context costs are the same shape everywhere. A tight content-script budget helps every engine.
Verification
- Run the budget check and confirm every entry is under its limit.
- Record a Performance trace of a page with your content script and look for its script evaluation; compare before and after a size change.
- Confirm lazy loading works — on a page without the target element, the heavy module should never be fetched:
1performance.getEntriesByType("resource").filter((e) => e.name.includes("content/enhance.js")).length;
2// 0 on pages without an <article>
Execution context: the page’s console with your extension’s context selected. A non-zero count on an irrelevant page means the lazy import is not conditional.
- Time a worker cold start before and after moving a library behind a dynamic import, using the measurement in reducing service worker cold start latency.
FAQ
Does minification help parse time?
It reduces bytes to parse, which helps a little. Removing code helps far more. Minify for release, but do not expect it to fix a content script that is ten times too large.
Should I tree-shake more aggressively?
Tree-shaking works on ES module imports with no side effects. A CommonJS dependency or a module with top-level side effects defeats it — the treemap will show that clearly.
Is 12 KB a realistic content-script budget?
For the always-injected part, yes, if heavy work is lazy-loaded. Many extensions ship a content script under 5 KB that loads the rest only where needed.
Related
- Measuring content script impact on page load — the cost this reduces.
- Reducing service worker cold start latency — the worker side.
- Bundling an MV3 extension with Vite — where the entry points are defined.
- Performance profiling and optimisation — the parent guide.