Manifest V2 to V3 Migration
Migrate a Manifest V2 extension to MV3 end to end: manifest key changes, background page to service worker, action APIs, chrome.scripting, remote code removal, DOM-free background code and the MV2 timeline.
Chrome no longer runs Manifest V2 extensions for ordinary users, Edge has followed, and the Chrome Web Store stopped accepting MV2 updates. For an extension that was built in the MV2 era, migration is not optional — and it is rarely a weekend job, because MV3 changed the execution model rather than just the manifest format. The background page that lived forever became a service worker that is terminated when idle; blocking web requests became declarative rules; tabs.executeScript with a code string became chrome.scripting with functions and files; and every form of remotely hosted code was banned. This topic sits inside Manifest V3 Architecture & Extension Lifecycle and organises the work around a single plan: the MV2 to MV3 migration checklist.
The guides here cover the changes that most migrations hit and that the two largest pieces of work — the service worker and network blocking — are covered elsewhere: how to migrate from Manifest V2 to V3 service workers and migrating webRequest to declarativeNetRequest step by step. Treat this topic as the map and those as two large regions on it.
Prerequisites checklist
- A list of every
chrome.*API the MV2 extension calls, from a grep of the source rather than from memory. - A list of every place code is constructed or fetched at runtime:
eval,new Function,setTimeoutwith strings, remote<script>tags, code strings passed toexecuteScript. - An inventory of state the background page keeps in memory or in
localStorage. - Automated tests, or at least a written manual test script, covering every feature before you start.
- A test profile with the current MV2 version installed and populated with realistic data, to test the update path.
- A decision about Firefox and Safari, which still accept MV2 and have their own MV3 differences.
Manifest registration: before and after
1// MV2
2{
3 "manifest_version": 2,
4 "background": { "scripts": ["bg.js"], "persistent": true },
5 "browser_action": { "default_popup": "popup.html" },
6 "permissions": ["tabs", "storage", "webRequest", "webRequestBlocking", "<all_urls>"],
7 "web_accessible_resources": ["inject.js", "icons/*.png"],
8 "content_security_policy": "script-src 'self' https://cdn.example.com; object-src 'self'"
9}
1// MV3
2{
3 "manifest_version": 3,
4 "background": { "service_worker": "sw.js", "type": "module" }, // no DOM, evictable
5 "action": { "default_popup": "popup.html" }, // browser_action/page_action merged
6 "permissions": ["storage", "scripting", "declarativeNetRequest"], // blocking webRequest removed
7 "host_permissions": ["<all_urls>"], // hosts split out of permissions
8 "web_accessible_resources": [{
9 "resources": ["inject.js", "icons/*.png"],
10 "matches": ["https://*.example.com/*"] // who may load them
11 }],
12 "content_security_policy": {
13 "extension_pages": "script-src 'self'; object-src 'self'" // no remote script sources allowed
14 }
15}
Execution context: the manifest, parsed at install. Chrome rejects MV3 manifests that still use browser_action, a string CSP, string-array web_accessible_resources or hosts inside permissions, and the error on chrome://extensions names the key. Firefox accepts MV3 but prefers background.scripts (an event page) over service_worker; a build step that emits both is covered in generating a manifest per browser target. Safari accepts both forms.
1. The background page becomes a service worker
Everything that depended on the background page being permanent breaks: global variables reset on eviction, setInterval stops, window and document do not exist, and listeners registered asynchronously miss the events that wake the worker. This is the largest piece of most migrations.
1// MV2 bg.js — implicit assumptions everywhere
2let cache = {}; // lives forever
3setInterval(refresh, 60_000); // runs forever
4chrome.storage.local.get("cfg", ({ cfg }) => {
5 chrome.runtime.onMessage.addListener(handle); // registered late — fine in MV2
6});
7
8// MV3 sw.js — explicit state, top-level listeners, alarms
9chrome.runtime.onMessage.addListener(handle); // top level, always
10chrome.alarms.create("refresh", { periodInMinutes: 1 });
11chrome.alarms.onAlarm.addListener((a) => a.name === "refresh" && refresh());
12async function getCache() { return (await chrome.storage.session.get("cache")).cache ?? {}; }
Execution context: the service worker. Every listener must be registered synchronously in the first pass of the script; state must live in chrome.storage; timers longer than a few seconds must be alarms. The details, including the five-minute event limit and how to keep long tasks alive, are in how to migrate from Manifest V2 to V3 service workers. DOM-dependent code needs a different home, covered in replacing DOM and XHR in the background context.
2. Blocking webRequest becomes declarativeNetRequest
MV2 extensions that blocked, redirected or modified requests in a webRequestBlocking listener must express the same behaviour as rules the browser evaluates on its own. Most blocking and header logic translates directly; logic that depended on inspecting request bodies or computing decisions per request with arbitrary code does not, and needs a design change.
1// MV2
2chrome.webRequest.onBeforeRequest.addListener(
3 (d) => ({ cancel: BLOCKLIST.has(new URL(d.url).hostname) }),
4 { urls: ["<all_urls>"] }, ["blocking"]
5);
6
7// MV3 — the same intent as a dynamic rule set
8await chrome.declarativeNetRequest.updateDynamicRules({
9 removeRuleIds: [...Array(BLOCKLIST.size).keys()].map((i) => i + 1),
10 addRules: [...BLOCKLIST].map((host, i) => ({
11 id: i + 1, priority: 1,
12 action: { type: "block" },
13 condition: { requestDomains: [host] },
14 })),
15});
Execution context: the service worker. Large lists belong in static rulesets declared in the manifest, which have far higher limits than dynamic rules. Observation without blocking still works through chrome.webRequest, as described in observing network requests with webRequest in MV3. The full migration is in migrating webRequest to declarativeNetRequest step by step.
3. Actions and injection APIs change names and shapes
chrome.browserAction and chrome.pageAction merged into chrome.action, and chrome.tabs.executeScript, insertCSS and removeCSS moved to chrome.scripting with a different argument shape and no support for code strings. Both changes are mechanical once you know the mapping, and both have behavioural edges — page actions that were hidden by default, executeScript calls that relied on returning the last expression’s value.
1// MV2
2chrome.browserAction.setBadgeText({ text: "3" });
3chrome.tabs.executeScript(tabId, { code: `document.title = ${JSON.stringify(t)}` });
4
5// MV3
6chrome.action.setBadgeText({ text: "3" });
7chrome.scripting.executeScript({ target: { tabId }, func: (t) => { document.title = t; }, args: [t] });
Execution context: the service worker. func is serialised and run in the tab — it cannot close over variables from the worker, which is why args exists. The mappings are in moving from browser action and page action to action and replacing tabs.executeScript with chrome.scripting.
4. Remotely hosted code is gone
MV3 forbids executing code that is not in the package. That rules out remote <script> tags in extension pages, eval and new Function (blocked by the mandatory CSP), code strings in executeScript, and the common MV2 pattern of downloading “rules” that were really JavaScript. Configuration data fetched remotely is still fine; logic is not. Libraries that use eval internally — older templating engines, some JSON schema validators, certain analytics snippets — must be replaced or configured to avoid it.
1# Find candidates before the store reviewer does
2grep -rnE "\beval\(|new Function\(|setTimeout\(\s*['\"]|setInterval\(\s*['\"]|<script[^>]+src=['\"]https?:" src/
Execution context: a terminal over the source tree and over node_modules for bundled dependencies. Chrome’s CSP blocks these at runtime with an error naming the directive, but finding them statically avoids shipping a broken build. The replacement patterns — data-driven configuration, bundled interpreters, sandboxed pages — are in replacing remotely hosted code.
5. Ship the migration to existing users
Users do not install the MV3 version; they receive it as an update to the MV2 extension they already have. That update runs chrome.runtime.onInstalled with reason: "update" in a brand-new service worker, which must migrate whatever the MV2 background page left behind — data in localStorage that the worker cannot read, alarms that never existed, settings in formats the new code does not expect.
1chrome.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => {
2 if (reason !== "update" || !previousVersion?.startsWith("2.")) return;
3 // localStorage from the MV2 background page is not readable here — migrate via an offscreen document
4 await migrateLegacyLocalStorage();
5 await chrome.alarms.create("refresh", { periodInMinutes: 1 });
6});
Execution context: the new service worker, running once after the update. MV2 background pages wrote localStorage on the extension origin, which an MV3 offscreen document can still read; the worker itself has no localStorage. Test this path with a real profile containing the MV2 version’s data — it is the step most migrations skip and the one that loses users’ settings. See running data migrations on onInstalled.
6. Testing a migration properly
A migrated extension fails in ways the MV2 version never could, so the tests that passed before are necessary but not sufficient. The new failure modes cluster around termination: state that vanishes when the worker is evicted, listeners that miss the event that woke the worker, and alarms that were never re-created after the update. None of them appear in a manual test where the developer clicks around for two minutes with DevTools open — and DevTools being open keeps the worker alive, hiding exactly these bugs.
1// Playwright: force a worker restart between steps to expose lost state
2const [sw] = context.serviceWorkers();
3await page.click("#save");
4await sw.evaluate(() => self.registration.unregister?.()); // or stop via CDP
5await page.reload();
6await expect(page.locator("#saved-count")).toHaveText("1");
Execution context: an end-to-end test with the unpacked MV3 build. Stopping the worker between actions — through the Chrome DevTools Protocol’s ServiceWorker.stopWorker, or by closing every extension page and waiting past the idle timeout — simulates what happens to real users dozens of times a day. Add a test for each feature that performs an action, forces a restart, and checks the result. The approach is covered in driving service worker state from a test.
Test the update path separately. Install the last MV2 release from a packed .crx into a fresh profile, use it enough to create real data, then load the MV3 build as an update to the same extension id. Confirm settings survived, alarms exist, and every feature works without the user doing anything. This is the only test that reflects what most of your users will experience.
7. Releasing the migration
Ship the MV3 version as a staged rollout rather than to everyone at once. The Chrome Web Store lets you publish an update to a percentage of users and raise it as confidence grows, which turns a migration bug into an incident affecting a few percent of users instead of all of them.
Wire up error reporting before the first percentage goes out, so you can see uncaught errors from the new worker in users’ browsers rather than waiting for one-star reviews. Keep the previous MV2 package and its signing setup available until the rollout completes; rolling back means publishing a higher version number containing the old code, as described in rolling back a bad extension release. Staged rollouts themselves are covered in staged rollouts in the Chrome Web Store.
Cross-cutting concerns: permissions, CSP and review
Migration is a good moment to shrink permissions, and a bad moment to add them. Splitting hosts into host_permissions does not by itself change the install warning, but an MV3 update that adds any warning-bearing permission disables the extension until users accept it — on top of the risk the migration already carries. Replace tabs with activeTab where possible, narrow host patterns, and move rarely used capabilities to optional permissions before the MV3 release rather than after.
The MV3 CSP for extension pages is fixed at a minimum of script-src 'self'; you can make it stricter but not looser, except for 'wasm-unsafe-eval' to allow WebAssembly compilation. Sandboxed pages declared under sandbox.pages can still run eval, isolated from extension APIs — a useful escape hatch for templating engines during a transition. Store review treats a migration like any other update, with the remote-code rules enforced strictly.
Planning the migration as a series of releases
Large extensions rarely migrate in a single release. A safer sequence is to first ship an MV2 build that already uses promise-based APIs, storage instead of background-page globals, and chrome.scripting-style injection helpers, so the code is MV3-shaped before the manifest changes. Then ship the MV3 manifest with feature parity and no new features, watch error rates per version for a week, and only afterwards remove the compatibility code. Each step is small enough to roll forward quickly if something breaks, and users never experience a release that changes behaviour and architecture at the same time.
MV3 constraints box
- No persistent background. Service workers are terminated after about thirty seconds idle;
"persistent": truehas no MV3 equivalent. - No blocking webRequest for store-installed Chrome extensions, except
onAuthRequiredviawebRequestAuthProvider. - No remote or string code.
eval,new Function, remote scripts and code strings inexecuteScriptare all blocked. - No DOM in the background.
window,document,DOMParser,XMLHttpRequestandlocalStorageare unavailable in the worker. - Rule limits. declarativeNetRequest caps static, dynamic and session rules; very large MV2 blocklists must be compressed into static rulesets.
- New key formats.
action, object-form CSP, object-formweb_accessible_resourcesand separatehost_permissionsare required.
Cross-browser notes
| Area | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| MV2 still runs | No (consumer builds) | Yes, no removal announced | Yes |
| MV3 background | service_worker | scripts event page (worker also accepted) | service_worker or non-persistent page |
Blocking webRequest in MV3 | No | Yes | No |
chrome.scripting | Yes | Yes (102+) | Yes |
chrome.action | Yes | Yes | Yes |
| Remote code ban | Enforced | Enforced by AMO policy | Enforced |
Firefox’s continued MV2 support means a cross-browser extension can migrate Chrome first and keep Firefox on MV2 for a while — but maintaining two background architectures is expensive. Most teams converge on one MV3 codebase with per-browser manifest generation.
When you do converge, write the shared code against the strictest engine’s rules: top-level listener registration and storage-backed state (Chrome’s service worker), no blocking webRequest outside Firefox-only modules, and no reliance on Firefox’s content-script cross-origin privileges. Code written to those rules runs in Firefox’s event page without modification, because an event page is strictly more capable than a service worker. The reverse is not true — code that leans on Firefox’s extra capabilities is the most common source of “works in Firefox, broken in Chrome” reports after a migration.
What this section covers
Start with the MV2 to MV3 migration checklist, which sequences the whole job. Then work through the replacements: replacing remotely hosted code, moving from browser action and page action to action, replacing tabs.executeScript with chrome.scripting and replacing DOM and XHR in the background context. For dates, enterprise policies and what still runs where, see the MV2 deprecation timeline and enterprise exceptions.
Related
- Service worker fundamentals — the execution model you are migrating to.
- declarativeNetRequest rules — the replacement for blocking webRequest.
- Extension updates and data migration — getting existing users across safely.
- Manifest keys reference — the MV3 keys in detail.
- Manifest V3 Architecture & Extension Lifecycle — the parent section.