"Every MV2-era tutorial on the open internet is a minefield. The patterns still read as obvious — they just don't load anymore."
Background Page → Service Worker
The single biggest break. MV2's persistent background page lived in memory for the entire browser session — you could stash state on globals, hold long-lived event listeners, build up caches, and it all stayed alive. MV3's service worker is event-driven and gets evicted after about 30 seconds of inactivity. Wake it up with an event (message, alarm, tab change, install) and it spins back up cold: globals reset, in-memory caches empty, listeners re-registered from the top of the file.
The migration pattern: anything that needs to survive eviction goes into chrome.storage.local (synchronous-feeling, persists across worker restarts). Anything you held as in-memory state in MV2 either becomes a storage round-trip or accepts that it dies on every wake.
Blocking webRequest → declarativeNetRequest
MV2's chrome.webRequest let extensions intercept every request, inspect headers, and synchronously block or modify it. That was the entire mechanism behind ad blockers. MV3 deprecated the blocking mode for non-enterprise extensions and introduced declarativeNetRequest — a rule-based system where you declare patterns up front, and Chrome enforces them without your code in the loop.
The tradeoff: rule-based is faster and more privacy-preserving (the extension never sees the network requests it doesn't act on), but it's less flexible. Complex ad blockers had to redesign matching logic into static rules. For ClipDeck, this section is informational — no network interception needed.
chrome.extension.* → chrome.runtime.*
Several MV2 APIs got renamed or merged into chrome.runtime in MV3:
chrome.extension.getBackgroundPage()→ no replacement (service workers can't be "gotten" — message them instead viachrome.runtime.sendMessage)chrome.extension.getViews()→chrome.extension.getViews()still works for popup/options pages, but service worker can't be "viewed"chrome.extension.sendMessage→chrome.runtime.sendMessagechrome.extension.onMessage→chrome.runtime.onMessage
If you copy-paste an MV2 snippet that uses chrome.extension, half the calls work and half don't. The errors panel will show chrome.extension.X is not a function for the missing ones. Find-and-replace is usually enough; check Chrome's migration docs for the per-symbol mapping.
Persistent State Loss
This is the gotcha that gets every MV2 dev exactly once. Code looks fine, listens to events fine, but state appears to reset randomly. What's actually happening: the service worker got evicted, restarted on the next event, and ran from the top of the file again — so any module-level const cache = new Map() you populated earlier is a fresh empty Map.
The fix: treat the service worker as stateless and use chrome.storage.local as the state layer. Read at the top of each event handler, write at the bottom. Track 2 dives into this pattern; for now, the gotcha is the lesson.
The Migration Checklist
If you ever need to migrate an MV2 extension to MV3, walk this list:
manifest_version: 2 → 3.background.scripts+persistent: true→background.service_worker(single file).- Remove any inline
<script>tags oronclick=handlers from extension HTML pages. - Find all
chrome.extension.*calls; map tochrome.runtime.*per the docs. - Replace blocking
webRequestwithdeclarativeNetRequestrules (or escalate to enterprise policy if rule-based is impossible). - Move all module-level state to
chrome.storage.local. - Audit
permissions: split intopermissions(APIs) andhost_permissions(URL patterns). - Check
web_accessible_resourcesshape — MV3 wraps the array in objects withresources/matches.
That sequence catches roughly 95% of MV2 → MV3 migrations. The remaining 5% are edge cases worth their own Chrome docs deep-dive.
/develop/ in it; the MV2-era guides used /extensions/.