MV2 to MV3 Migration Checklist
A sequenced checklist for migrating a Manifest V2 extension to MV3: inventory, manifest conversion, service worker, network rules, injection, remote code, UI surfaces, update path and staged release.
Table of Contents
You have an MV2 extension, a deadline, and no clear picture of how big the job is. The temptation is to change manifest_version to 3, fix whatever chrome://extensions complains about, and ship. That produces an extension that loads and then fails in production — because the hard parts of MV3 are runtime behaviours, not manifest errors. This checklist sequences the work so that each step builds on a tested previous one, and so that the riskiest changes are made while you can still see their effects. It belongs to Manifest V2 to V3 migration.
Why order matters
MV3 changes interact. Moving to a service worker changes when listeners run, which changes how chrome.scripting calls are triggered, which changes what activeTab grants are available. Replacing blocking webRequest changes which permissions you need, which changes the install warning, which affects whether existing users must re-consent. Doing these in an arbitrary order means debugging several changes at once. The order below front-loads discovery (so you know the size of the job), then makes the manifest load, then tackles the background because almost every other change depends on it, and leaves the release mechanics — which depend on everything else — for last.
Step-by-step checklist
1. Inventory what the extension actually uses
1# Every chrome.* namespace and method in use, with counts
2grep -rhoE "chrome\.[a-zA-Z]+\.[a-zA-Z]+" src | sort | uniq -c | sort -rn > api-usage.txt
3
4# MV2-only or changed APIs that need attention
5grep -rnE "browserAction|pageAction|tabs\.executeScript|tabs\.insertCSS|webRequestBlocking|extension\.getBackgroundPage|getViews|chrome\.extension\.sendRequest" src
Execution context: a terminal over the source tree. The second grep is your work list: every hit is either a rename (actions), a shape change (scripting), an architecture change (getBackgroundPage has no MV3 equivalent because there is no page), or a removal (blocking webRequest). Record each in a tracking issue with the file and line. Do the same for localStorage, document, window, XMLHttpRequest and setInterval in background files.
- API usage list generated and every MV2-only call assigned to a step below.
- Background-page DOM and
localStorageusage listed. - Every dynamic-code construct (
eval,new Function, code strings) listed, including in dependencies.
2. Convert the manifest until it loads
1{
2 "manifest_version": 3,
3 "background": { "service_worker": "sw.js", "type": "module" },
4 "action": { "default_popup": "popup.html", "default_icon": "icons/32.png" },
5 "permissions": ["storage", "scripting", "alarms"],
6 "host_permissions": ["https://*.example.com/*"],
7 "web_accessible_resources": [{ "resources": ["inject.js"], "matches": ["https://*.example.com/*"] }],
8 "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }
9}
Execution context: the manifest. Load it unpacked and fix each error chrome://extensions reports; the messages name the offending key. At this point the extension loads but many features will not work — that is expected.
-
browser_action/page_action→action. - Hosts moved from
permissionstohost_permissions. -
web_accessible_resourcesconverted to objects withmatches. - CSP converted to the object form with no remote sources.
-
background.scripts+persistent→background.service_worker.
3. Rebuild the background as a service worker
This is the core of the migration. Work through it methodically rather than fixing errors as they appear.
1// sw.js — skeleton every migrated worker should start from
2import { handleMessage } from "./messages.js";
3import { onAlarm } from "./jobs.js";
4
5chrome.runtime.onInstalled.addListener(onInstalled); // all listeners first,
6chrome.runtime.onStartup.addListener(onStartup); // synchronously,
7chrome.runtime.onMessage.addListener(handleMessage); // at the top level
8chrome.alarms.onAlarm.addListener(onAlarm);
9chrome.action.onClicked.addListener(onActionClick);
10
11async function onInstalled(details) { await ensureAlarms(); await migrate(details); }
12async function onStartup() { await ensureAlarms(); }
Execution context: the service worker. Listeners first, logic second; state in chrome.storage; periodic work in alarms; DOM work in an offscreen document. Then test every feature with the worker forcibly stopped between steps. See how to migrate from Manifest V2 to V3 service workers and registering listeners at the top level.
- All listeners registered synchronously at the top level.
- Global state moved to
chrome.storage.sessionorlocal. -
setInterval/longsetTimeoutreplaced bychrome.alarms. - DOM,
DOMParser,XMLHttpRequestandlocalStorageremoved from the worker. -
getBackgroundPage()callers in popups rewritten to message the worker.
4. Replace network blocking and injection
- Blocking
webRequestlisteners rewritten as declarativeNetRequest static or dynamic rules. - Observational
webRequestlisteners kept, without"blocking". -
tabs.executeScript/insertCSS/removeCSSreplaced withchrome.scriptingequivalents. - Code strings replaced with
func+argsor bundled files.
Details are in migrating webRequest to declarativeNetRequest step by step and replacing tabs.executeScript with chrome.scripting.
5. Remove remote and dynamic code
1npx esbuild src/sw.js --bundle --outfile=/tmp/sw.bundle.js && \
2 grep -nE "\beval\(|new Function\(" /tmp/sw.bundle.js | head
Execution context: a terminal. Grepping the bundled output catches dependencies that use eval internally, which a source grep misses. Each hit needs a replacement library, a configuration change, or a move into a sandboxed page. See replacing remotely hosted code.
- No
eval/new Functionin any bundle that runs in an extension context. - No remote
<script>sources in extension pages. - Remote configuration reduced to data, validated before use.
6. Test the update path and release in stages
1chrome.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => {
2 if (reason === "update") console.info("[migrate] from", previousVersion);
3});
Execution context: the new service worker after an update from the MV2 build. Install the last MV2 release in a clean profile, create realistic data, update to the MV3 build, and confirm every setting and feature survives. Then publish as a staged rollout with error reporting live.
- MV2 → MV3 update tested on a populated profile.
-
localStoragedata from the MV2 background migrated (via an offscreen document). - Error reporting wired into every context.
- Staged rollout plan and rollback package ready.
Common mistakes
- Testing with DevTools open. An open DevTools window keeps the service worker alive, hiding every lost-state and late-listener bug. Close it and wait thirty seconds before testing each feature.
- Fixing errors in the order they appear. Load errors come from the manifest; runtime errors come from the background architecture. Fixing them one by one leads to patches like “keep the worker alive forever” instead of a correct design.
- Forgetting existing users. A fresh install of the MV3 build is not what your users get. Their update path, with MV2-era data, is the real test.
- Adding permissions during migration. Any new warning-bearing permission disables the extension for every existing user until they accept it, compounding the migration’s risk.
- Leaving Firefox for later without a plan. Firefox still runs MV2, so it will not force the issue — but two diverging codebases get harder to merge every month.
Cross-browser variation
- Chrome / Edge: MV2 is no longer available to consumer users; the migration is mandatory and the service worker background is required.
- Firefox: MV2 remains supported. Its MV3 uses an event page by default, so code following this checklist runs there too, with
background.scriptsin a Firefox-specific manifest. - Safari: accepts MV2 and MV3. Converting with
xcrun safari-web-extension-converterworks for either; Safari’s MV3 supports a service worker or a non-persistent background page.
Verification
chrome://extensionsshows the extension as MV3 with no errors or warnings.- Every feature passes the three gates above with DevTools closed.
grepacross all bundles finds no dynamic-code constructs.- An MV2 → MV3 update on a populated profile preserves all user data.
- Error rates in the staged rollout match or improve on the MV2 baseline.
FAQ
How long does a migration take?
For a small extension with no network blocking, days. For one with a large blocking ruleset or heavy background DOM use, weeks. The inventory in step 1 gives you a real estimate.
Can I migrate incrementally?
Not within one extension — the manifest version is all or nothing. You can, however, refactor the MV2 code towards MV3 patterns first (storage-backed state, top-level listeners, no eval) and flip the manifest last.
Should I rewrite instead of migrating?
Only if the MV2 background’s design is fundamentally incompatible, such as one that depends on holding a large in-memory index for hours. Most extensions migrate faster than they rewrite.
Related
- Replacing remotely hosted code — step 5 in depth.
- Replacing DOM and XHR in the background context — step 3’s hardest cases.
- MV2 deprecation timeline and enterprise exceptions — how much time you have where.
- Manifest V2 to V3 migration — the parent topic.