Skip to content
Writing
Jan 21, 20263 min read

What Manifest V3 actually changes about building Chrome extensions

MV3 isn't a config migration. It changes the runtime model, and most of the friction comes from the one thing nobody mentions up front.

  • Chrome MV3
  • Plasmo
  • React

If you've only read the migration guide, Manifest V3 looks like paperwork: rename a few keys, swap background.scripts for background.service_worker, move to chrome.action. Then you build one and find out the interesting part wasn't in the diff.

Your background page is gone, not renamed

MV2 gave you a persistent background page — a long-lived context you could keep state in. MV3 replaces it with a service worker that the browser will terminate whenever it feels like it, typically after about thirty seconds of inactivity.

This breaks a specific, very common pattern: module-scope state.

// MV2: fine. MV3: a bug that only shows up after 30 idle seconds.
let cachedToken: string | null = null;

export async function getToken() {
  if (!cachedToken) cachedToken = await fetchToken();
  return cachedToken;
}

It works in development, because you're constantly poking the extension and the worker never sleeps. It fails in production for the user who leaves the tab open over lunch. Every piece of state that needs to outlive a single event has to live in chrome.storage, and every entry point has to assume it's starting from nothing.

Same story for timers. setTimeout past the termination window doesn't fire — chrome.alarms is the replacement, with a floor on how frequently it can run.

Three runtimes that can't see each other

An extension isn't one app. It's a service worker, content scripts injected into pages, and popup/options pages — each with a separate lifecycle, separate bundle, and no shared memory. The only way across is message passing.

The mistake is treating this as an inconvenience to paper over with a global store. It isn't; it's the architecture. What helped us was deciding early, per piece of state, which context owns it, and making the others ask:

  • The service worker owns anything needing auth or a network call.
  • Content scripts own anything about the current DOM.
  • The popup owns nothing. It renders what it's told and sends intents.

Once that's written down, the messaging stops feeling like plumbing and starts being an actual API boundary.

No remote code, and it's enforced

MV3 blocks executing code fetched at runtime. Practically: no CDN scripts, no eval, and no libraries that construct functions from strings. A dependency that works everywhere else can fail review because of a transitive package doing something clever.

This is worth checking before you're attached to a library, not after.

The tooling is most of the pain

Everything above is inherent to MV3. The rest — bundling three entry points with different formats, wiring HMR into a context the browser reloads on its own terms, keeping the manifest in sync with the files that exist — is incidental, and it eats a surprising amount of a project.

We standardised on Plasmo for this. File-system conventions determine what becomes a content script, a popup or a background worker; the manifest is generated; live reload works, including for content scripts. It's opinionated in ways you'll occasionally fight, and that trade was clearly worth it: the build config stopped being a thing anyone thought about.

The other benefit was less obvious. Because Plasmo is React-first, extension code shared idioms with our web apps. The same developer could move between them without relearning a toolchain — which matters more on a small team than any individual build feature.

The short version

Treat MV3 as a runtime change, not a config change. Assume the background context dies constantly, put every durable piece of state in chrome.storage, decide explicitly which context owns what, and let a framework handle the bundling so your time goes into the product.

Written by Mohammad Ali. Got a take, or think I got something wrong? Tell me.