Manifest V3 Is Here — And It Broke Everything
Google's Manifest V3 migration deadline has come and gone. After migrating 17 Chrome extensions from MV2 to MV3, I've compiled every pitfall, workaround, and lesson learned.
If you're still migrating — or building new extensions — this guide will save you weeks of debugging.
Pitfall 1: Service Workers Die Without Warning
The problem: MV3 replaces persistent background pages with service workers. Service workers are terminated after ~30 seconds of inactivity. Any state stored in global variables is lost.
What broke: My subscription checking code stored the user's payment status in a variable. After the service worker restarted, the variable was undefined, and paid users saw free-tier limitations.
The fix: Never store state in global variables. Use chrome.storage for everything:
// BAD: Lost when service worker restarts
let userIsPaid = false;
// GOOD: Persisted across restarts
async function isPaid(): Promise<boolean> {
const { subscriptionCache } = await chrome.storage.local.get('subscriptionCache');
return subscriptionCache?.paid ?? false;
}
Bonus pitfall: chrome.storage.session exists but is only accessible from the service worker by default. If you need it in popup/content scripts, you must call chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' }) in the service worker.
Pitfall 2: webRequest Blocking Is Gone
The problem: chrome.webRequest.onBeforeRequest with blocking capability no longer exists. Extensions that modified or blocked requests must use declarativeNetRequest.
What broke: FocusGuard used webRequest to redirect blocked sites. The entire blocking mechanism stopped working.
The fix: Migrate to declarativeNetRequest with dynamic rules:
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [{
id: 1,
priority: 1,
action: {
type: 'redirect',
redirect: { extensionPath: '/blocked.html' }
},
condition: {
urlFilter: '*://*.twitter.com/*',
resourceTypes: ['main_frame']
}
}],
removeRuleIds: [1]
});
Gotcha: Dynamic rules have a limit of 5,000 rules per extension. If your extension needs to block thousands of URLs, use the rule_resources approach with static rulesets instead.
Pitfall 3: Alarm Minimum Is 1 Minute
The problem: chrome.alarms.create enforces a minimum period of 1 minute in production (30 seconds in development).
What broke: My subscription refresh used a 30-second polling interval. In production, it silently upgraded to 60 seconds, causing stale data.
The fix: Design around the 1-minute minimum. For anything that needs sub-minute precision, use setTimeout inside the service worker — but remember the worker can be terminated. For critical timing, accept the 1-minute granularity.
Pitfall 4: Content Script Communication Changes
The problem: When the service worker is inactive, chrome.runtime.sendMessage from a content script can fail silently or throw.
What broke: Content scripts in my extensions would call the background for subscription status. If the service worker was sleeping, the Promise would hang forever.
The fix: Always add timeouts and fallbacks:
async function getSubscription(): Promise<SubscriptionInfo> {
// Check cache first
const cache = await chrome.storage.local.get('subscriptionCache');
if (cache.subscriptionCache?.timestamp > Date.now() - 300000) {
return cache.subscriptionCache;
}
// Ask background with timeout
return new Promise((resolve) => {
const timeout = setTimeout(() => resolve(cache.subscriptionCache || DEFAULT), 3000);
try {
chrome.runtime.sendMessage({ action: 'getSubscription' }, (res) => {
clearTimeout(timeout);
if (chrome.runtime.lastError || !res) {
resolve(cache.subscriptionCache || DEFAULT);
return;
}
resolve(res);
});
} catch {
clearTimeout(timeout);
resolve(cache.subscriptionCache || DEFAULT);
}
});
}
Pitfall 5: Downloads API Requires User Gesture
The problem: chrome.downloads.download() now requires a user gesture in some contexts. Programmatic downloads from background scripts may fail.
What broke: DataPick's export feature triggered downloads from the content script via the background. It worked in MV2 but silently failed in MV3.
The fix: Trigger downloads from the content script directly using a Blob URL and an anchor click, or ensure the background download is in direct response to a user action message.
Pitfall 6: executeScript Changes
The problem: chrome.tabs.executeScript is replaced by chrome.scripting.executeScript with a different API shape.
The old way:
chrome.tabs.executeScript(tabId, { code: 'document.title' });
The new way:
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.title,
});
console.log(result.result); // The page title
Gotcha: The func parameter must be a serializable function. It cannot reference variables from the outer scope. Pass data via the args parameter.
Pitfall 7: Extension Size and Permissions Scrutiny
The problem: MV3 extensions face stricter review. Google now flags extensions with broad permissions (<all_urls>, tabs, etc.) and large bundle sizes.
What broke: Two of my extensions were rejected for requesting activeTab + <all_urls> together, which was considered redundant.
The fix:
- Request minimum permissions
- Use
activeTabinstead of host permissions where possible - Provide permission justifications in the CWS developer dashboard
- Keep bundle sizes small (tree-shake aggressively)
My MV3 Migration Checklist
After 17 migrations, here's my go-to checklist:
- [ ] Replace all global state with chrome.storage
- [ ] Migrate webRequest to declarativeNetRequest
- [ ] Replace chrome.tabs.executeScript with chrome.scripting.executeScript
- [ ] Add timeout/fallback to all runtime.sendMessage calls
- [ ] Test with service worker restart (chrome://serviceworker-internals)
- [ ] Verify alarms work with 1-minute minimum
- [ ] Review and minimize permissions
- [ ] Test content script ↔ background communication after SW sleep
- [ ] Verify downloads work without persistent background
Key Takeaway
MV3 is a fundamentally different programming model. The service worker lifecycle changes everything. Design for statelessness from day one, and treat the service worker as an unreliable intermediary that might not be running when you need it.
Built by S-Hub — 17 Chrome extensions, all running on Manifest V3.
Explore S-Hub Extensions
- Procshot — Auto-capture browser steps
- DataPick — Extract data from any webpage
- FocusGuard — Block distracting sites
See all extensions at dev-tools-hub.xyz
Top comments (0)