DEV Community

SHOTA
SHOTA

Posted on

Manifest V3 Migration Pitfalls — Lessons from 17 Chrome Extensions

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;
}
Enter fullscreen mode Exit fullscreen mode

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]
});
Enter fullscreen mode Exit fullscreen mode

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);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

The new way:

const [result] = await chrome.scripting.executeScript({
  target: { tabId },
  func: () => document.title,
});
console.log(result.result); // The page title
Enter fullscreen mode Exit fullscreen mode

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 activeTab instead 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

See all extensions at dev-tools-hub.xyz

Top comments (0)