5 min read

Chrome MV2 Is Gone: What Migrating My Extension to MV3 Actually Broke

Last Friday I opened the Chrome Web Store dashboard to check on a tiny tab-timer extension I've maintained since 2022, and the status page had a new banner on it: MV2 submissions are no longer supported, and MV2 extensions have been pulled from the store. That was the top story on Hacker News this morning too, so a lot of people saw some version of it. My extension still ran fine in the browser. It just didn't exist as far as new users were concerned.

I'd been ignoring the migration for two years. Honestly, I get it: MV3 has been "coming" since 2021, and every deadline Google set slid. But this weekend I finally sat down and ported it, and the work was not the version bump the manifest file pretends it is. Here's what actually broke, in the order it broke.

Laptop showing browser developer tools during extension debugging

Your global variables are lies now

The MV2 background page was a real page. It stayed alive, so any state you hung off a global variable just stayed there. Mine kept a tabTimers object mapping tab IDs to start timestamps, and it worked flawlessly for three years because the page never died.

MV3 replaces that page with a service worker, and Chrome kills the service worker after roughly 30 seconds of inactivity. Wake it up for a new event and your globals are back to undefined. I lost accumulated timer data on every wake-up before I understood this.

The fix is to treat chrome.storage as your only real memory:

// MV3: read state inside the handler, never trust memory
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
  const { tabTimers = {} } = await chrome.storage.local.get('tabTimers');
  if (!tabTimers[tabId]) {
    tabTimers[tabId] = { startedAt: Date.now(), elapsed: 0 };
  }
  await chrome.storage.local.set({ tabTimers });
});

One nuance I got wrong at first: don't read storage once at module level and cache it. The service worker can wake up before that read resolves, and you'll handle an event against an empty cache. Read inside the handler, every time. It feels wasteful. It isn't, at extension scale.

setInterval is a coin flip

My timer tick used setInterval in the background. In MV2 that interval ran forever. In MV3, the interval dies with the service worker, and Chrome decides when that happens. Sometimes my badge updated every second. Sometimes not at all for ten minutes.

The replacement is the alarms API, and it has opinions. Minimum period is one minute. Alarms survive service worker restarts because Chrome, not your code, owns them:

chrome.alarms.create('tick', { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'tick') updateBadges();
});

For anything sub-minute I gave up and let the popup run its own setInterval while it's open. The popup has a normal page lifecycle, so timers work there. The badge ticks once a minute when nobody's looking, which turned out to be fine.

The silent message drop

This one cost me an evening. A content script sends a message to the service worker with chrome.runtime.sendMessage. If the worker happens to be mid-shutdown, the message just vanishes. No error thrown in the content script. No log anywhere. The promise never resolves.

I thought my content script logic was broken. It wasn't. The worker was dying at the wrong moment, randomly. The fix is an ugly but honest retry wrapper:

async function sendWithRetry(msg, tries = 3) {
  for (let i = 0; i < tries; i++) {
    try {
      return await chrome.runtime.sendMessage(msg);
    } catch {
      await new Promise(r => setTimeout(r, 150 * 2 ** i));
    }
  }
  throw new Error('background unreachable');
}

Also, if you do async work in an onMessage listener, you must return true from it, or Chrome closes the message channel before your sendResponse runs. The silent-drop behavior again. I've stopped being surprised by that pattern.

Smaller cuts

Some things that broke without earning their own section. browser_action and page_action merged into action. XMLHttpRequest is gone from the worker; it's fetch only. Listeners must be registered synchronously at the top level of the worker script, because Chrome replays missed events against whatever listeners exist at startup. And my one DOM-touching helper moved to an offscreen document, which is MV3's official answer to "service workers have no DOM."

The webRequest-to-declarativeNetRequest change is the big one for ad blockers, and it's why uBlock Origin got pulled. My extension never touched network interception, so I skipped that fight entirely. If you maintain something in that space, I'm sorry. That's a rewrite, not a migration.

Was the end state better? I'll say something unfashionable: yes, a little. Forcing all state through storage means the extension now survives browser restarts without me ever having thought about crash recovery. The async-everywhere style is annoying to write and pleasant to reread. The alarm minimum interval pushed a design decision I should have made anyway.

I kept the full migration diff and the retry wrapper in my snippet manager, because I have three more MV2 stragglers to port and I already know I'll forget the return true thing again. If you're staring down the same migration: do the storage refactor first, everything else follows from it.