MV2 vs MV3 — The Four Breaking Changes
These are the four changes that will actually break your code or reject your submission.
1. Service worker replaces the persistent background page
MV2 background pages stayed alive indefinitely and held state in memory. MV3 replaces them with a service worker that terminates when idle. Any state stored in a JS variable is gone when it wakes back up. You need chrome.storage for anything that has to persist.
2. action replaces browser_action and page_action
Two separate APIs collapsed into one. If your manifest still declares browser_action, it will be ignored. Use "action" in MV3.
3. declarativeNetRequest replaces webRequest
This is the one that breaks ad blockers and request-intercepting tools. webRequest gave extensions real-time access to modify or block network requests. declarativeNetRequest uses a pre-declared rules file instead — the browser does the matching, your extension never sees the raw request. More performant, far less flexible. If you are building anything that intercepts requests dynamically, plan for this early.
4. Remote code execution is blocked entirely
eval(), new Function(), and injecting remote scripts via <script src="https://..."> in extension pages are gone. All logic must be bundled locally. If you were loading a CDN library in your popup HTML, it needs to be vendored.
The MV3 File Structure
A minimal MV3 extension looks like this:
my-extension/
├── manifest.json
├── popup.html
├── popup.css
├── popup.js
├── background.js (optional — service worker)
├── content.js (optional — injected into pages)
└── icons/
├── icon16.png
├── icon48.png
└── icon128.png
manifest.json is the only required file — everything else is declared from it. If your extension has no popup, skip popup.html. If it doesn't need to run on pages, skip content.js. Keep the structure flat until you have a reason to introduce subdirectories.
Complete MV3 manifest.json
{
"manifest_version": 3, // Must be 3 for new submissions
"name": "My Extension",
"version": "1.0",
"description": "What it does in one sentence.",
"action": { // Replaces browser_action and page_action
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js" // Not "scripts" — that's MV2 syntax
},
"content_scripts": [
{
"matches": ["https://example.com/*"], // Scope this — don't default to <all_urls>
"js": ["content.js"],
"run_at": "document_idle"
}
],
"host_permissions": [
"https://api.yourservice.com/*" // Required if fetch() hits external URLs
],
"permissions": [
"storage", // For chrome.storage.local / .sync
"activeTab" // Only grants access on user gesture
]
}
popup.js — The Two Things That Trip Everyone
1. Wrap everything in DOMContentLoaded
Your popup HTML loads, the browser parses it, then runs the linked script. If your JS tries to query a DOM element before the HTML is parsed, document.getElementById() returns null and nothing works. The fix:
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('myButton');
btn.addEventListener('click', () => {
// your logic here
});
});
Without this wrapper, you will spend time debugging what looks like a broken event listener when the actual issue is a null reference.
2. Use chrome.storage.local, not localStorage
localStorage works in a normal browser tab. In an extension popup, the popup is its own isolated page — it opens, does its work, and closes. Any localStorage data is scoped to that page's origin and, more critically, is not accessible from background service workers or content scripts. Use chrome.storage.local instead:
// Write
chrome.storage.local.set({ userPreference: 'dark' });
// Read
chrome.storage.local.get(['userPreference'], (result) => {
console.log(result.userPreference);
});
chrome.storage is accessible from every extension context (popup, service worker, content script). localStorage is not.
Load Unpacked + Debug
Go to chrome://extensions, enable Developer mode (toggle top right), click Load unpacked, and select your extension folder. The extension loads immediately — no packaging needed. To debug the popup, right-click the extension icon and choose Inspect Popup. This opens a standard DevTools window scoped to popup.js. For the service worker, click the Service Worker link directly on the extensions card in chrome://extensions.
The Mistake I See Most Often in Generated Extension Code
Building Extinde, a tool that generates MV3 extensions from plain English prompts, means reviewing a lot of generated and hand-written extension code. These four patterns cause the most silent failures and review rejections:
"matches": ["<all_urls>"] in content_scripts
This triggers the broadest permission warning shown to users on install. Most extensions don't actually need to run on every URL. Scope it to the domains you need. Chrome Web Store reviewers flag over-permissioning, and users see the warning before installing.
chrome.storage.sync when you need chrome.storage.local
sync has a 100KB total quota and an 8KB per-item limit. It's designed for small preference flags that sync across a user's devices. If you're storing anything substantial — cached data, user-generated content, API responses — use local, which has a 10MB quota.
Missing host_permissions when fetch() is used in a content script
If your content script calls fetch('https://api.example.com/...') and you haven't declared that host in "host_permissions", the request will fail silently in some cases and throw a CORS error in others. The fix is a single line in your manifest — but it's easy to miss because the error message doesn't always point back to the manifest.
Wrong path in "service_worker"
If background.js sits in a src/ folder and your manifest says "service_worker": "background.js", the service worker silently fails to register. No error on load, no console output — the worker just doesn't exist. Double-check that the path in the manifest matches exactly where the file lives relative to the manifest root.
Build Without the Boilerplate
Full publish walkthrough — Chrome Web Store submission, Firefox AMO, Edge Add-ons — is on the Extinde blog (link in canonical above). If you want to skip writing the manifest, file structure, and wiring from scratch, extinde.com/create generates MV3-compliant extensions from a plain English prompt. Free tier includes 25 credits, no card required.
Tags: chrome webdev tutorial javascript
Top comments (1)
What's your biggest MV2 to MV3 migration pain point? Happy to help debug in the comments.