[{"data":1,"prerenderedAt":4},["ShallowReactive",2],{"post-content-chrome-mv2-removed-mv3-migration-gotchas":3},"\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1618477388954-7852f32655ec?w=1200&fm=jpg&q=75&auto=format\" alt=\"Laptop showing browser developer tools during extension debugging\" loading=\"lazy\" \u002F>\n\n\u003Ch2>Your global variables are lies now\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>tabTimers\u003C\u002Fcode> object mapping tab IDs to start timestamps, and it worked flawlessly for three years because the page never died.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>undefined\u003C\u002Fcode>. I lost accumulated timer data on every wake-up before I understood this.\u003C\u002Fp>\n\n\u003Cp>The fix is to treat \u003Ccode>chrome.storage\u003C\u002Fcode> as your only real memory:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F MV3: read state inside the handler, never trust memory\nchrome.tabs.onActivated.addListener(async ({ tabId }) =&gt; {\n  const { tabTimers = {} } = await chrome.storage.local.get('tabTimers');\n  if (!tabTimers[tabId]) {\n    tabTimers[tabId] = { startedAt: Date.now(), elapsed: 0 };\n  }\n  await chrome.storage.local.set({ tabTimers });\n});\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>setInterval is a coin flip\u003C\u002Fh2>\n\n\u003Cp>My timer tick used \u003Ccode>setInterval\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>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:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">chrome.alarms.create('tick', { periodInMinutes: 1 });\nchrome.alarms.onAlarm.addListener((alarm) =&gt; {\n  if (alarm.name === 'tick') updateBadges();\n});\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>For anything sub-minute I gave up and let the popup run its own \u003Ccode>setInterval\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>The silent message drop\u003C\u002Fh2>\n\n\u003Cp>This one cost me an evening. A content script sends a message to the service worker with \u003Ccode>chrome.runtime.sendMessage\u003C\u002Fcode>. 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.\u003C\u002Fp>\n\n\u003Cp>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:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">async function sendWithRetry(msg, tries = 3) {\n  for (let i = 0; i &lt; tries; i++) {\n    try {\n      return await chrome.runtime.sendMessage(msg);\n    } catch {\n      await new Promise(r =&gt; setTimeout(r, 150 * 2 ** i));\n    }\n  }\n  throw new Error('background unreachable');\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Also, if you do async work in an \u003Ccode>onMessage\u003C\u002Fcode> listener, you must \u003Ccode>return true\u003C\u002Fcode> from it, or Chrome closes the message channel before your \u003Ccode>sendResponse\u003C\u002Fcode> runs. The silent-drop behavior again. I've stopped being surprised by that pattern.\u003C\u002Fp>\n\n\u003Ch2>Smaller cuts\u003C\u002Fh2>\n\n\u003Cp>Some things that broke without earning their own section. \u003Ccode>browser_action\u003C\u002Fcode> and \u003Ccode>page_action\u003C\u002Fcode> merged into \u003Ccode>action\u003C\u002Fcode>. \u003Ccode>XMLHttpRequest\u003C\u002Fcode> is gone from the worker; it's \u003Ccode>fetch\u003C\u002Fcode> 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.\"\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>return true\u003C\u002Fcode> thing again. If you're staring down the same migration: do the storage refactor first, everything else follows from it.\u003C\u002Fp>\n",1788264748787]