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.

Published September 18, 2026 Updated September 18, 2026 7 min read
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

Daily parse-and-evaluate cost of 100 KB, by where it shipsApproximate milliseconds per day spent parsing and evaluating 100 KB of JavaScript placed in the options page, the popup, the service worker and a content script matching all URLs.Options page (opened rarely)15 ms per dayPopup (opened 20×)300 ms per dayService worker (60 cold starts)900 ms per dayContent script, <all_urls> (300 pages)4500 ms per d…
The same bytes cost two orders of magnitude more in a broadly matched content script than in the options page.

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.

Size budgets by entry pointSuggested uncompressed size budgets for the content script, service worker, popup and options page, with how often each is parsed and why the budget is set where it is.EntryBudgetParsedWhycontent/main.js≤ 12 KBEvery matching pagePaid on every navigationservice-worker.js≤ 60 KBEvery cold startDelays the first eventpopup.js≤ 80 KBEvery openDelays first paintoptions.js≤ 250 KBRarelyOpened on purpose
The content script's budget is the tightest because it is the one paid on pages the user never asked the extension to touch.

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.

Cutting size where it paysMeasure per entry point, identify the dominant dependency, move it behind a lazy import or replace it with a platform API, and lock the gain in with a CI budget.Size per entrynot per packageTreemapfind the dominant depRemove or replaceplatform APIs firstthen keep it that wayLazy-load the restinside handlersBudget per entrycontent script tightestFail the PRwith the file name
One dependency is usually half the problem — find it before tuning anything else.

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

  1. Run the budget check and confirm every entry is under its limit.
  2. Record a Performance trace of a page with your content script and look for its script evaluation; compare before and after a size change.
  3. 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.

  1. 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.

Other Testing, Debugging & Performance Optimization Resources