Manifest V3 Migration Pitfalls — Lessons from 17 Chrome Extensions

Published: (May 3, 2026 at 08:00 PM EDT)
5 min read
Source: Dev.to

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 pitfallchrome.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.webRequestdeclarativeNetRequest

The problemchrome.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 problemchrome.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 problemchrome.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.executeScriptchrome.scripting.executeScript

The problemchrome.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 activeTab instead 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 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 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

See all extensions at dev-tools-hub.xyz

0 views
Back to Blog

Related posts

Read more »