BulkMD

Vite + Manifest V3: Bundling a Chrome Extension Right

How to bundle a Manifest V3 extension with Vite without tripping the no-remote-code policy — single-file service workers, asset paths, and IIFE outputs.

M. H. Tawfik11 min read

If you have tried to ship a Manifest V3 Chrome extension with Vite and watched Chrome's loader reject the build with "Service worker registration failed", the culprit is almost always the same: Vite produced a service-worker bundle that depends on a separate chunk file via dynamic import, and Manifest V3 forbids that. The default Vite settings are tuned for web apps, where lazy chunking is a feature; for an extension, lazy chunking is a deal-breaker. This post is the configuration we landed on for BulkMD after running into every variation of this problem, and the reasoning behind each setting.

The companion post on the service-worker runtime — what to do at execution time once the bundle actually loads — is Manifest V3 service workers for bulk URL processing. That post assumed you had a working build; this one is how you get there.

Why Manifest V3 makes bundling hard

The Chrome Web Store's "no remote code" policy, baked into Manifest V3, means everything that runs inside your extension must be present in the submitted ZIP. No fetch('https://cdn.example.com/module.js'), no dynamic imports of URLs not packaged with the extension, no eval of strings that came from anywhere except the extension's own files. Google enforces this during review; violations result in publication blocks that are surprisingly hard to debug from the rejection notice alone.

For a bundler, this turns out to be more restrictive than it looks. Modern bundlers — Vite, Webpack, esbuild, Rollup — are designed to split code into chunks that the runtime loads on demand. A 200-line service worker that imports a 50-KB utility module gets compiled into two files by default: the entry, plus a chunk. The entry contains import("./chunk-abc123.js") somewhere, and the runtime fetches the chunk at execution time. On the web this is great; in an MV3 service worker, the chunk fetch is treated as a remote-code load even though both files are in your extension's ZIP, and Chrome refuses to register the worker.

The fix is to instruct the bundler to produce a single file per entry — no chunks, no dynamic imports, all dependencies inlined. This is what Vite's lib mode does naturally for libraries, but the right combination of settings for an extension takes some work to discover.

The Vite config that works

The following vite.config.js is what we ship in BulkMD, simplified to the load-bearing settings:

import { defineConfig } from "vite";
import { resolve } from "node:path";

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        background: resolve(__dirname, "src/background.ts"),
        content: resolve(__dirname, "src/content.ts"),
        popup: resolve(__dirname, "popup.html"),
        dashboard: resolve(__dirname, "dashboard.html"),
        options: resolve(__dirname, "src/options.ts"),
      },
      output: {
        entryFileNames: "[name].js",
        chunkFileNames: "[name].js",
        assetFileNames: "[name].[ext]",
        format: "iife",
        inlineDynamicImports: false,
      },
      preserveEntrySignatures: "strict",
    },
    target: "chrome120",
    minify: "esbuild",
    sourcemap: true,
    emptyOutDir: true,
  },
});

Several of these settings are the meaningful ones; the rest are housekeeping. The input map declares each manifest entry as its own bundle root — service worker, content script, popup, options page, dashboard. The output.format: "iife" is what flips the build from ES-module to immediately-invoked-function-expression form, which packages everything that entry needs into a single file with no module-level imports.

The entryFileNames: "[name].js" (without content hashes) is critical because the manifest references these files by name. A hashed filename like background-3f9c7a.js would force a manifest update on every build; the stable filename means the manifest can hard-code "background.js" once and stay correct forever.

The target: "chrome120" tells esbuild that you can use modern syntax — top-level await, optional chaining, all of it. There is no point compiling down to ES2018 for a runtime that has always been current Chromium.

When to enable inlineDynamicImports

Vite has an inlineDynamicImports flag that, when true, forces dynamic import() calls in your source to be resolved at build time rather than emitted as chunk loads. This is what you want for the service worker entry, where any chunk load would crash. But it has a constraint: with multiple inputs, Vite refuses to enable it globally because the inlining strategy assumes a single entry.

The shape that works in practice is to split the build into two passes when you have dynamic imports anywhere in your codebase. The first pass builds the service worker as its own Vite invocation with inlineDynamicImports: true and a single entry. The second pass builds the rest of the extension (content scripts, popup, options) as a multi-entry build with inlineDynamicImports: false. The two builds write into the same dist/ directory and produce the final ZIP-able artifact.

For an extension that does not use dynamic imports at all — most of them, in our experience — the single-pass multi-input config above is enough. The format: "iife" plus entryFileNames: "[name].js" is sufficient to keep each entry self-contained.

How big is the difference, really?

We measured BulkMD's bundle output across three build configurations to surface what changes when you tune for MV3 compliance versus the Vite defaults.

Build configurationService worker sizeChunks producedLoads in MV3
Vite default (ES modules)18 KB + 6 chunks (76 KB total)6No (registration fails)
Vite lib mode with ES output84 KB0No (top-level imports rejected)
IIFE multi-input (above config)89 KB0Yes
IIFE with inlineDynamicImports (single-pass)91 KB0Yes

The size difference between the "default + chunks" and "IIFE single file" configurations is roughly 10–15 KB for our codebase — the cost of inlining dependencies that would otherwise have been chunk-shared between entries. This is a real cost on big extensions; for BulkMD it is well below the bundle-size budget. The cost is worth it because the alternative is an extension that does not load at all.

The middle row — Vite lib mode with ES output — is the trap many tutorials lead you into. It produces a single file (good), but the file uses ES-module syntax with top-level import statements (bad), which Chrome rejects in service workers. IIFE is the only output format that produces both a single file and a self-executing top-level structure that MV3 accepts.

Common pitfalls beyond the config

A few patterns reliably break MV3 extensions built with Vite even after the config above is correct.

The first is import.meta.url references. Vite resolves new URL("./asset.png", import.meta.url) at build time for ES modules, but the IIFE output does not have a meaningful import.meta.url — it ends up as the extension's root, and your asset references break. The workaround is to use chrome.runtime.getURL("asset.png") for any extension-relative asset and avoid import.meta.url entirely in extension code.

The second is process.env.NODE_ENV baking in the wrong value. Vite's default-replaces process.env.NODE_ENV at build time, and if you build with vite build without setting NODE_ENV=production, you ship a development bundle to the Chrome Web Store. Verify in your CI that production builds set the variable explicitly. We use cross-env NODE_ENV=production vite build in our npm run build script.

The third is wasm files. If you depend on any package that ships a .wasm file (some Markdown renderers, some sanitizers), Vite tries to load it via fetch() of the wasm URL — which is fine on the web and broken in MV3. The fix is to inline the wasm as a base64 string in the bundle, which most bundlers can do with a plugin (@rollup/plugin-wasm works in Vite via rollupOptions.plugins). Inlined wasm adds significant size but ships compliantly.

The fourth is sourcemaps. Sourcemaps with absolute paths reveal your build environment's file structure in the published extension. Chrome Web Store reviewers occasionally flag this; users with curiosity find it embarrassing. Set sourcemap: true for dev and sourcemap: false (or sourcemap: "hidden") for production.

Putting it together with the manifest

The manifest itself should reference exactly the filenames the bundle produces. For the config above, that is:

{
  "manifest_version": 3,
  "name": "BulkMD",
  "version": "1.3.0",
  "background": { "service_worker": "background.js", "type": "module" },
  "content_scripts": [
    { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }
  ],
  "action": { "default_popup": "popup.html" },
  "options_page": "options.html"
}

Two things worth flagging in this manifest. The "type": "module" on the service worker is correct for IIFE or ES output — it tells Chrome to set up the worker context but does not require ES-module syntax inside the file. Removing it is fine for pure IIFE; keeping it is also fine and gives you the option to ship ES output later if Chrome relaxes the dynamic-import restriction. We keep it.

The other is the <all_urls> match pattern on content scripts — narrow this in production to the URLs you genuinely need. Excessive permissions are an SEO-adjacent concern (Chrome Web Store reviewers and users both penalize them), and a narrow match like ["https://*.example.com/*"] ships faster through review.

TL;DR

Bundling a Manifest V3 extension with Vite requires telling the bundler to produce a single file per manifest entry, in IIFE format, with stable non-hashed filenames. The default Vite settings tuned for web apps produce a chunked ES-module output that MV3 refuses to load; the configuration above is the minimal change set that produces a working build. Beyond the config, watch for import.meta.url, process.env.NODE_ENV, embedded wasm, and source-map leakage as the load-bearing pitfalls.

If you want to see the pattern shipped in a real production extension, BulkMD is the Chrome Web Store listing, and the bundling architecture above is exactly what produces its .zip artifact. The output is the version we have shipped through dozens of Chrome Web Store reviews without a remote-code flag.

Frequently asked questions

Can I use Webpack or esbuild instead of Vite for the same outcome?

Yes — the constraints are bundler-agnostic. Webpack with the `output.iife: true` option (introduced in webpack 5) produces the same shape. esbuild's `--format=iife --bundle` produces it as well. The reason we use Vite is dev-server ergonomics (HMR on the dashboard, fast cold rebuilds), but for the production build itself any bundler that emits single-file IIFE works.

What about Rollup directly?

Rollup directly works and is what Vite uses under the hood. The downside is that Vite handles a lot of TypeScript, JSX, and CSS preprocessing for you that you'd have to wire up in raw Rollup. For an extension with a popup UI built in React, the Vite-with-Rollup-under-the-hood stack is the path of least resistance.

How do I handle dev-mode hot reloading with this setup?

Vite's dev server does not work inside a Chrome extension because the extension can't load from `localhost:5173`. We run `vite build --watch` in development, which rebuilds the extension on file changes; Chrome's `chrome://extensions` page has a reload button you click after each rebuild. There are plugins (`@crxjs/vite-plugin`) that wire up reload automation, but they introduce their own opinions; the manual-reload flow is simpler and stable.

Does the no-remote-code policy ever ship exemptions?

There is one narrow exemption: code loaded from a Trusted Types-protected sandbox iframe is permitted. In practice this is too restrictive to be useful for normal extension features; it exists primarily for extensions that need to execute user-supplied script in a strongly isolated environment. For 99% of extensions, plan to bundle everything.

What size is too big for a Chrome extension bundle?

Functionally, the Chrome Web Store accepts ZIPs up to 100MB; practically, anything above 5MB starts to feel sluggish on install and may trigger user-side warnings. Most extensions land between 200KB and 2MB. If you find yourself approaching 5MB, audit your dependencies — a bundled image library, a fully-shipped wasm file, or an embedded font is usually the culprit, and replacing it with a smaller alternative is cheaper than fighting the bundler.

About the author

M. H. Tawfik

Lead Developer & Owner

Working from Kushtia, Bangladesh.

Independent software engineer building developer tools at Soft Web Grove. Creator and maintainer of BulkMD.

Reach the team at [email protected] — typically within 24 hours, any day of the year. Soft Web Grove also takes a small number of outside engagements; details on the about page.

ShareXinHN
TaggedManifest V3Chrome extensionService workerBulk export