Manifest V3 Migration Guide
I've migrated 16 Chrome extensions from Manifest V2 to Manifest V3. Some took an afternoon. Some took two weeks of debugging service worker lifecycle issues that made me question my career choices.
The Chrome Web Store stopped accepting new MV2 extensions a while back, and Google has been progressively disabling MV2 extensions for users. If you still have MV2 extensions, the clock ran out. You need to migrate.
This guide covers every breaking change I encountered across those 16 migrations, with the exact code transformations you need. I'm going to show the MV2 code, the MV3 equivalent, and the gotchas that documentation doesn't warn you about.
The Big Picture: What Changed
MV3 isn't a minor version bump. It's a fundamental shift in how Chrome extensions work. Here are the major changes:
| Feature | MV2 | MV3 |
|---|---|---|
| Background scripts | Persistent background pages | Service workers (non-persistent) |
| Network request modification | webRequestBlocking |
declarativeNetRequest |
| Browser/page action | browser_action / page_action |
action (unified) |
| Content security policy | String format, remote code allowed | Object format, no remote code |
| Host permissions | In permissions array |
Separate host_permissions field |
| Script execution | chrome.tabs.executeScript |
chrome.scripting.executeScript |
| Remote code execution | Allowed | Forbidden |
The service worker change is the one that causes the most pain. Everything else is mostly find-and-replace.
Step 1: Update the Manifest
Start with the manifest.json file. This is where most changes are concentrated.
MV2 Manifest
{
"manifest_version": 2,
"name": "My Extension",
"version": "1.0.0",
"description": "Does useful things",
"browser_action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"scripts": ["background.js"],
"persistent": false
},
"permissions": [
"activeTab",
"storage",
"https://api.example.com/*"
],
"content_security_policy": "script-src 'self'; object-src 'self'",
"web_accessible_resources": [
"images/*.png",
"styles/injected.css"
]
}
MV3 Manifest
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "Does useful things",
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": [
"activeTab",
"storage"
],
"host_permissions": [
"https://api.example.com/*"
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"web_accessible_resources": [
{
"resources": ["images/*.png", "styles/injected.css"],
"matches": ["<all_urls>"]
}
]
}
Key changes in the manifest:
"manifest_version": 3- obvious but easy to forget when copy-pasting"browser_action"becomes"action". If you had"page_action", that's also now just"action""background"switches from"scripts"array to a single"service_worker"string. Add"type": "module"if you want ES module imports- Host permissions move from
"permissions"to"host_permissions" "content_security_policy"changes from a string to an object with scoped keys"web_accessible_resources"changes from a flat array to an array of objects with"resources"and"matches"
"type": "module" in the background field is optional but extremely useful. Without it, you can't use import statements in your service worker. I add it to every new extension now. But be aware: if you use "type": "module", your service worker runs in strict mode, which can break sloppy code that relies on implicit globals or this referring to the global scope.
Step 2: Service Workers Replace Background Pages
This is the big one. In MV2, your background script ran in a full page context with access to the DOM (window, document, etc.). It could be persistent (always running) or event-based (wakes up for events, then goes idle).
In MV3, the background script is a service worker. No DOM access. No window object. No document. No XMLHttpRequest (use fetch instead). And critically: the service worker can be terminated at any time when idle, and it will be restarted the next time an event fires.
The Lifecycle Problem
This lifecycle behavior is what causes 80% of MV3 migration bugs. In MV2, you could do this:
// MV2 - This works fine with a persistent background page
let userData = null;
chrome.runtime.onInstalled.addListener(() => {
userData = { theme: 'dark', count: 0 };
});
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'GET_USER') {
sendResponse(userData); // Always available
}
if (msg.type === 'INCREMENT') {
userData.count++;
sendResponse(userData);
}
});
In MV3, this breaks. The service worker terminates after about 30 seconds of inactivity. When it restarts, userData is null because the in-memory state is gone. Your popup sends a message, gets back null, and everything falls apart.
The Fix: Persist State to Storage
// MV3 - Service worker safe version
chrome.runtime.onInstalled.addListener(async () => {
await chrome.storage.session.set({
userData: { theme: 'dark', count: 0 }
});
});
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'GET_USER') {
chrome.storage.session.get('userData', (result) => {
sendResponse(result.userData || null);
});
return true; // Keep message channel open for async response
}
if (msg.type === 'INCREMENT') {
chrome.storage.session.get('userData', (result) => {
const userData = result.userData || { theme: 'dark', count: 0 };
userData.count++;
chrome.storage.session.set({ userData }, () => {
sendResponse(userData);
});
});
return true;
}
});
Notice the return true in the message listener. That's critical. It tells Chrome to keep the message channel open because you're going to call sendResponse asynchronously. Without it, the channel closes immediately and your response never arrives. This was already the case in MV2 for async responses, but it's easy to forget when rewriting code.
chrome.storage.session was added specifically for MV3. It's like chrome.storage.local but the data is cleared when the browser closes. Use it for temporary state that doesn't need to survive browser restarts. It's faster than storage.local because it doesn't need to write to disk.
No DOM in Service Workers
If your MV2 background page used any DOM APIs, you need alternatives.
| MV2 (Background Page) | MV3 (Service Worker) |
|---|---|
new XMLHttpRequest() |
fetch() |
document.createElement('canvas') |
OffscreenCanvas or chrome.offscreen API |
document.createElement('audio') |
chrome.offscreen API |
window.localStorage |
chrome.storage.local |
new DOMParser() |
chrome.offscreen API |
setTimeout (long delays) |
chrome.alarms API |
setInterval |
chrome.alarms API |
The Offscreen API
When you absolutely need DOM access from the background context, the chrome.offscreen API creates an invisible document that can use DOM APIs. You need the "offscreen" permission.
// background.js (service worker)
// Create an offscreen document for DOM operations
async function getOffscreenDocument() {
const existingContexts = await chrome.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT']
});
if (existingContexts.length > 0) {
return; // Already exists
}
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSER'],
justification: 'Parse HTML content from API responses'
});
}
// Use it to parse HTML
async function parseHTML(htmlString) {
await getOffscreenDocument();
const response = await chrome.runtime.sendMessage({
target: 'offscreen',
type: 'PARSE_HTML',
data: htmlString
});
return response;
}
// offscreen.html
<!DOCTYPE html>
<script src="offscreen.js"></script>
// offscreen.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.target !== 'offscreen') return;
if (msg.type === 'PARSE_HTML') {
const parser = new DOMParser();
const doc = parser.parseFromString(msg.data, 'text/html');
const title = doc.querySelector('title')?.textContent || '';
const links = [...doc.querySelectorAll('a')].map(a => a.href);
sendResponse({ title, links });
return true;
}
});
The offscreen API is clunky. I won't sugarcoat it. Having to create a separate HTML file and bounce messages back and forth is a lot of ceremony for something that used to be a two-line DOM operation. But it works, and it's the sanctioned way to do it.
Timers and Alarms
setTimeout and setInterval technically work in service workers. The problem is that the service worker can be terminated before the timer fires. If you set a setTimeout for 5 minutes, the service worker will be killed long before that.
// MV2 - Background page timer
setInterval(() => {
checkForUpdates();
}, 30 * 60 * 1000); // Every 30 minutes
// MV3 - Use chrome.alarms instead
// Need "alarms" permission in manifest
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('checkUpdates', {
periodInMinutes: 30
});
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'checkUpdates') {
checkForUpdates();
}
});
chrome.alarms has a minimum interval of 30 seconds (it used to be 1 minute). If you need something to run more frequently than that, you'll need a different approach. One option is keeping the service worker alive by maintaining an active connection from a content script or popup. But be careful - Chrome may flag extensions that artificially keep service workers alive.
Step 3: Replace webRequestBlocking with declarativeNetRequest
This change affects ad blockers, privacy extensions, and anything that modifies network requests. In MV2, you could observe and modify every request in real-time using webRequest and webRequestBlocking. In MV3, you declare rules upfront and Chrome's engine applies them.
MV2 Approach
// MV2 - Block requests matching patterns
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
if (details.url.includes('tracking.js')) {
return { cancel: true };
}
if (details.url.includes('old-domain.com')) {
return {
redirectUrl: details.url.replace(
'old-domain.com', 'new-domain.com'
)
};
}
},
{ urls: ["<all_urls>"] },
["blocking"]
);
MV3 Approach
First, add the permission and rule file to your manifest:
{
"permissions": ["declarativeNetRequest"],
"declarative_net_request": {
"rule_resources": [
{
"id": "my_rules",
"enabled": true,
"path": "rules.json"
}
]
}
}
Then create the rules file:
// rules.json
[
{
"id": 1,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "tracking.js",
"resourceTypes": ["script"]
}
},
{
"id": 2,
"priority": 1,
"action": {
"type": "redirect",
"redirect": {
"transform": {
"host": "new-domain.com"
}
}
},
"condition": {
"urlFilter": "||old-domain.com",
"resourceTypes": [
"main_frame", "sub_frame", "script",
"stylesheet", "image", "xmlhttprequest"
]
}
}
]
The declarative approach is fundamentally different. Instead of running JavaScript on every request (which is powerful but slow and a privacy concern), you're giving Chrome a list of rules and it handles the matching internally. It's faster. It's more private. But it's also less flexible.
Dynamic Rules
If you need to add or modify rules at runtime (based on user preferences, for example), use the dynamic rules API:
// Add rules dynamically
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [
{
id: 100,
priority: 1,
action: { type: 'block' },
condition: {
urlFilter: userSpecifiedDomain,
resourceTypes: ['main_frame']
}
}
],
removeRuleIds: [100] // Remove old version first
});
You can have up to 5,000 dynamic rules and 300,000 static rules. That's enough for most use cases. But if you're building something like uBlock Origin that needs hundreds of thousands of dynamic, content-dependent rules, MV3 is genuinely limiting. That's a real trade-off, not just theoretical.
Step 4: Update API Calls
chrome.browserAction becomes chrome.action
Simple rename. Every method is the same, just under a different namespace.
// MV2
chrome.browserAction.setIcon({ path: 'icon.png' });
chrome.browserAction.setBadgeText({ text: '5' });
chrome.browserAction.setBadgeBackgroundColor({ color: '#6C5CE7' });
chrome.browserAction.onClicked.addListener(handleClick);
// MV3
chrome.action.setIcon({ path: 'icon.png' });
chrome.action.setBadgeText({ text: '5' });
chrome.action.setBadgeBackgroundColor({ color: '#6C5CE7' });
chrome.action.onClicked.addListener(handleClick);
If you used chrome.pageAction, that's also now chrome.action. The concept of page-specific actions is handled by enabling/disabling the action for specific tabs:
// MV2 page action behavior in MV3
chrome.action.disable(); // Disabled by default
// Enable for specific tab
chrome.action.enable(tabId);
chrome.tabs.executeScript becomes chrome.scripting.executeScript
The new scripting API is more structured but also more powerful.
// MV2
chrome.tabs.executeScript(tabId, {
code: 'document.title'
}, (results) => {
console.log(results[0]);
});
chrome.tabs.executeScript(tabId, {
file: 'content-script.js'
});
chrome.tabs.insertCSS(tabId, {
file: 'styles.css'
});
// MV3 - Need "scripting" permission
chrome.scripting.executeScript({
target: { tabId: tabId },
func: () => document.title
}).then((results) => {
console.log(results[0].result);
});
chrome.scripting.executeScript({
target: { tabId: tabId },
files: ['content-script.js']
});
chrome.scripting.insertCSS({
target: { tabId: tabId },
files: ['styles.css']
});
code: 'some string of JS' to execute arbitrary code. In MV3, you pass a function reference with func. The function is serialized and sent to the content script context. This means you can't use closures that reference variables in the service worker scope. If you need to pass data, use the args parameter:
// Passing arguments to injected functions
const color = '#6C5CE7';
const selector = '.header';
chrome.scripting.executeScript({
target: { tabId: tabId },
func: (bgColor, sel) => {
document.querySelector(sel).style.backgroundColor = bgColor;
},
args: [color, selector]
});
No More Remote Code Execution
This one bit me on 3 of my 16 extensions. MV2 allowed you to fetch JavaScript from a remote server and execute it. MV3 forbids this entirely. All code must be included in your extension package.
// MV2 - This was allowed (and a security risk)
fetch('https://cdn.example.com/analytics.js')
.then(r => r.text())
.then(code => {
chrome.tabs.executeScript(tabId, { code: code });
});
// MV3 - NOT ALLOWED. You'll get an error.
// All scripts must be bundled with the extension.
If you need to load configuration or data from a remote server, you can still fetch() JSON data. You just can't execute remote code. The distinction is: data is fine, code is not.
For extensions that relied on remotely-hosted logic (A/B testing, feature flags, dynamic behavior), the fix is to bundle all possible code paths in the extension and use remote config to select which path to run.
Step 5: Content Security Policy
The CSP format changed from a string to an object, and the rules got stricter.
// MV2
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
// MV3
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
Two major restrictions in MV3:
'unsafe-eval'is no longer allowed for extension pages. If your code useseval(),new Function(), orsetTimeoutwith a string argument, it will break.- You can't reference remote scripts in the CSP. No CDN-hosted libraries on extension pages.
The 'unsafe-eval' restriction is the one that causes the most pain. Some libraries use eval or new Function internally. If you're using a library that does this, you'll need to find an alternative or use a bundled version that avoids eval.
Common offenders I've run into: older versions of handlebars templating, some JSON schema validators, and various "template literal" libraries. Check your dependencies.
Sandbox Pages
If you absolutely must run eval() (maybe you're building a code editor extension), you can use a sandboxed page:
// manifest.json
"sandbox": {
"pages": ["sandbox.html"]
}
// sandbox.html has relaxed CSP and can use eval()
// But it can't use Chrome extension APIs
// Communicate with it via window.postMessage()
I've used this for exactly one extension that needed to evaluate user-written JavaScript. It works but adds complexity. Avoid it if you can.
Step 6: Web Accessible Resources
In MV2, web accessible resources were available to all websites. In MV3, you specify which origins can access them.
// MV2 - Any website can access these
"web_accessible_resources": [
"images/logo.png",
"injected.css"
]
// MV3 - Specify which sites can access them
"web_accessible_resources": [
{
"resources": ["images/logo.png", "injected.css"],
"matches": ["https://example.com/*"]
},
{
"resources": ["content-styles.css"],
"matches": ["<all_urls>"]
}
]
This is actually a security improvement. In MV2, any website could detect your installed extensions by probing for web-accessible resource URLs. MV3 limits this to only the origins you specify. If your content script only runs on specific sites, restrict access accordingly.
"use_dynamic_url": true in a web accessible resource entry, Chrome generates a unique per-session URL for the resource. This prevents fingerprinting but means the URL changes every session, so you can't hardcode it. Access it via chrome.runtime.getURL().
Step 7: Storage API Changes
The storage API itself didn't change dramatically, but how you use it shifts because of the service worker lifecycle.
chrome.storage.session
New in MV3. This storage area is:
- In-memory only (fast reads/writes)
- Cleared when the browser session ends
- Not accessible from content scripts by default (you can enable it with
chrome.storage.session.setAccessLevel) - Limited to 10 MB by default
// Store temporary data that survives service worker restarts
// but not browser restarts
chrome.storage.session.set({ tempData: { key: 'value' } });
// Allow content scripts to access session storage
chrome.storage.session.setAccessLevel({
accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS'
});
I use storage.session for data that's too transient for storage.local but needs to survive service worker restarts. Things like cached API responses, active tab state, and temporary user preferences.
Promise-based API
All Chrome extension APIs in MV3 support promises (in addition to callbacks). This makes async code much cleaner:
// MV2 style (callbacks)
chrome.storage.local.get(['settings'], (result) => {
const settings = result.settings;
chrome.storage.local.set({ settings: { ...settings, theme: 'dark' } }, () => {
console.log('Saved');
});
});
// MV3 style (promises)
const { settings } = await chrome.storage.local.get(['settings']);
await chrome.storage.local.set({
settings: { ...settings, theme: 'dark' }
});
console.log('Saved');
So much cleaner. This alone made the migration worthwhile for some of my extensions that had deep callback nesting.
Step 8: Testing Your Migrated Extension
Testing MV3 extensions requires checking things that MV2 didn't care about. Here's my testing checklist:
Service Worker Lifecycle Tests
- Install and open popup. Does everything work on first load?
- Wait 30 seconds. Open the popup again. Is your state preserved?
- Wait 5 minutes without interacting. Open the popup again. Does the service worker wake up correctly? Is state intact?
- Open Chrome task manager (Shift+Esc). Watch the service worker appear and disappear. Does your extension handle the transitions gracefully?
- Disable and re-enable the extension. Does it recover state from storage?
Testing Service Worker Termination
You can force-terminate the service worker from chrome://serviceworker-internals/. Find your extension's service worker and click "Stop". Then trigger an event (click the icon, send a message from a content script). The service worker should restart and handle the event correctly.
I do this test religiously. If your extension breaks after a forced stop, users will see the same bug in production when Chrome terminates your service worker after idle time.
Permission Tests
- Install with all optional permissions denied. Does the core functionality work?
- Grant permissions one at a time. Does each feature activate correctly?
- Revoke a permission. Does the extension degrade gracefully?
Content Script Tests
- Test on HTTP and HTTPS pages.
- Test on pages with strict CSP headers.
- Test with other popular extensions installed (ad blockers, password managers). Extension conflicts are real.
- Test in incognito mode (extensions need explicit permission to run there).
Step 9: Common Migration Bugs and Fixes
Bug: "Service worker registration failed"
This usually means there's a syntax error in your service worker file. Service workers fail silently on syntax errors - they just don't register.
Fix: Open chrome://extensions, click "Errors" on your extension. The error message will point to the line with the issue. Common causes: using window, document, or other DOM globals that don't exist in service workers.
Bug: Messages not getting responses
If chrome.runtime.sendMessage from your popup or content script isn't getting a response, check two things:
- Is your message listener returning
truefor async responses? - Is the service worker alive when the message is sent? If it was terminated, Chrome should restart it automatically when a message arrives, but there can be a brief delay.
// WRONG - response is lost because listener returns before async completes
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
chrome.storage.local.get('data', (result) => {
sendResponse(result.data);
});
// Implicitly returns undefined (falsy)
});
// CORRECT - return true to keep channel open
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
chrome.storage.local.get('data', (result) => {
sendResponse(result.data);
});
return true; // This is the fix
});
Bug: Alarms not firing
If you create an alarm with a delayInMinutes less than 0.5 (30 seconds), Chrome silently adjusts it to 0.5. Your alarm will fire, just later than expected.
Also, alarms don't fire while the device is sleeping. If a user closes their laptop and reopens it 3 hours later, any missed alarms will fire once. Not individually for each missed interval - just once.
Bug: Extension popup closes during async operations
The popup is a separate page. When the user clicks away, it closes and its JavaScript context is destroyed. If your popup was in the middle of an async operation (API call, storage write), that operation is abandoned.
Fix: Move long-running operations to the service worker. The popup should only handle UI. Send a message to the service worker to start the operation, and have the service worker notify the popup (if it's still open) when done.
// popup.js - Don't do long operations here
document.getElementById('sync-btn').addEventListener('click', () => {
// Tell the service worker to handle it
chrome.runtime.sendMessage({ type: 'START_SYNC' });
// Update UI immediately
document.getElementById('status').textContent = 'Syncing...';
});
// Listen for completion
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'SYNC_COMPLETE') {
document.getElementById('status').textContent = 'Done!';
}
});
Step 10: Publishing the Update
When you're ready to publish the MV3 version on the Chrome Web Store:
- Increment your version number. Don't try to publish the same version with a different manifest version.
- Test with the actual .zip package, not just the unpacked extension. Sometimes file path issues only show up in the packaged version.
- Submit for review. MV3 extensions generally get approved faster than MV2 did, in my experience. My last three MV3 submissions were approved within 24 hours.
- Monitor crash reports for the first week. The Chrome Web Store dashboard shows crash reports. Service worker issues often manifest as crashes rather than errors.
Consider publishing as a staged rollout if the Web Store offers it for your account. Push to 10% of users first, monitor for a few days, then roll out to everyone.
Migration Checklist
Here's the checklist I use for every migration. Print it out or save it somewhere:
- Update
manifest_versionto 3 - Replace
browser_action/page_actionwithaction - Convert background scripts to service worker
- Move host permissions to
host_permissions - Update
content_security_policyto object format - Update
web_accessible_resourcesto new format - Replace
chrome.browserAction/chrome.pageActionwithchrome.action - Replace
chrome.tabs.executeScriptwithchrome.scripting.executeScript - Replace
chrome.tabs.insertCSSwithchrome.scripting.insertCSS - Remove all uses of
eval()andnew Function() - Remove all remote code loading
- Replace
XMLHttpRequestwithfetch - Replace
localStoragewithchrome.storage - Replace
setTimeout/setInterval(long-running) withchrome.alarms - Move in-memory state to
chrome.storage.session - Add
return trueto all async message listeners - Replace
webRequestBlockingwithdeclarativeNetRequestif applicable - Test service worker lifecycle (termination and restart)
- Test all features after 5-minute idle period
- Update version number and publish
Was It Worth It?
Honestly? MV3 is a better architecture for most extensions. The service worker model forces you to write stateless, event-driven code. The declarative net request API is faster than intercepting every request in JavaScript. The security improvements (no remote code, no eval) make the extension ecosystem safer.
But the migration itself is painful. Google could have provided better tooling and a longer transition period. The documentation improved significantly over time, but the early adopters (myself included) had to figure out a lot through trial and error.
If you're starting the migration now, you have it easier than I did. The APIs are stable, the documentation is decent, and there are plenty of real-world examples to reference. Follow this guide, test thoroughly, and you'll get through it.
And if you're building a new extension from scratch, start with MV3 from day one. There's no reason to learn MV2 patterns at this point. The future is service workers and declarative APIs. Get comfortable with them.