Manifest V3 Migration Pitfalls — Lessons from 17 Chrome Extensions
Source: Dev.to
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.
1. Service‑worker lifecycle & lost global state
The problem – MV3 replaces persistent background pages with service workers. Service workers are terminated after ~30 seconds of inactivity, so 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 {
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 popups/content scripts, call this from the service worker:
chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' });
2. chrome.webRequest → declarativeNetRequest
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] // optional: clean up old rules
});
Gotcha – Dynamic rules have a limit of 5,000 rules per extension. If you need to block thousands of URLs, use the rule_resources approach with static rulesets instead.
3. chrome.alarms minimum period
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 sub‑minute precision, use setTimeout inside the service worker — but remember the worker can be terminated. For critical timing, accept the 1‑minute granularity.
4. Messaging when the service worker is asleep
The problem – When the service worker is inactive, chrome.runtime.sendMessage from a content script can fail silently or throw.
What broke – Content scripts called the background for subscription status. If the service worker was sleeping, the Promise hung forever.
The fix – Always add timeouts and fallbacks:
async function getSubscription(): Promise {
// 1️⃣ Check cache first
const cache = await chrome.storage.local.get('subscriptionCache');
if (cache.subscriptionCache?.timestamp > Date.now() - 300_000) {
return cache.subscriptionCache;
}
// 2️⃣ 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);
}
});
}
5. chrome.downloads.download now needs a 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 – Either:
- Trigger downloads directly from the content script using a Blob URL and an anchor click, or
- Ensure the background download runs in direct response to a user‑action message.
6. chrome.tabs.executeScript → chrome.scripting.executeScript
The problem – chrome.tabs.executeScript is replaced by chrome.scripting.executeScript with a different API shape.
Old way
chrome.tabs.executeScript(tabId, { code: 'document.title' });
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.
7. Stricter MV3 review process
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 (aggressive tree‑shaking).
8. Post‑migration checklist
After 17 migrations, here’s my go‑to checklist:
- Replace all global state with
chrome.storage - Migrate
webRequesttodeclarativeNetRequest - Replace
chrome.tabs.executeScriptwithchrome.scripting.executeScript - Add timeout/fallback to all
runtime.sendMessagecalls - Test with service‑worker restart (
chrome://serviceworker-internals) - Verify alarms work with the 1‑minute minimum
- Review and minimize permissions
- Test content‑script ↔ background communication after SW sleep
- Verify downloads work without a persistent background
TL;DR
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 ephemeral helper rather than a persistent background page. Follow the checklist above and you’ll avoid the most common pitfalls.
Built by S‑Hub — 17 Chrome extensions, all running on Manifest V3.
- Procshot — Auto‑capture browser steps
- DataPick — Extract data from any webpage
- FocusGuard — Block distracting sites