C.W.K.
Stream
Lesson 06 of 06 · published

MV3 Gotchas — What Broke from MV2

~10 min · mv3, migration, gotchas, service-worker, deprecations

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"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 via chrome.runtime.sendMessage)
  • chrome.extension.getViews()chrome.extension.getViews() still works for popup/options pages, but service worker can't be "viewed"
  • chrome.extension.sendMessagechrome.runtime.sendMessage
  • chrome.extension.onMessagechrome.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:

  1. manifest_version: 2 → 3.
  2. background.scripts + persistent: truebackground.service_worker (single file).
  3. Remove any inline <script> tags or onclick= handlers from extension HTML pages.
  4. Find all chrome.extension.* calls; map to chrome.runtime.* per the docs.
  5. Replace blocking webRequest with declarativeNetRequest rules (or escalate to enterprise policy if rule-based is impossible).
  6. Move all module-level state to chrome.storage.local.
  7. Audit permissions: split into permissions (APIs) and host_permissions (URL patterns).
  8. Check web_accessible_resources shape — MV3 wraps the array in objects with resources/matches.

That sequence catches roughly 95% of MV2 → MV3 migrations. The remaining 5% are edge cases worth their own Chrome docs deep-dive.

MV3 didn't add features — it removed footguns. Every gotcha is a deliberate cut: less memory, less attack surface, less state to debug. The constraints are the design.
Old Stack Overflow answers will lie to you. The MV2 patterns dominated Chrome extension content for a decade. When in doubt, prefer the current Chrome Developers documentation over any third-party tutorial dated before 2023. The doc URL has /develop/ in it; the MV2-era guides used /extensions/.

Code

Cross-context call: MV2 direct reach vs MV3 message passing·javascript
// MV2 — gone. Service worker can't be "gotten" directly.
const bg = chrome.extension.getBackgroundPage();
bg.someSharedFunction();

// MV3 — message the worker instead.
const response = await chrome.runtime.sendMessage({
  type: "do-the-thing",
  payload: { x: 1 },
});

// And in background.js:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "do-the-thing") {
    sendResponse({ ok: true, result: doIt(message.payload) });
  }
  return false; // synchronous response
});
Module-level state: MV2 Set vs MV3 chrome.storage.local round-trip·javascript
// MV2 — synchronous in-memory state survives forever
let visitedTabs = new Set();
chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
  if (info.status === "complete") visitedTabs.add(tab.url);
});
// later... visitedTabs.size is the running count

// MV3 — state must survive worker eviction
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
  if (info.status !== "complete") return;
  const { visitedUrls = [] } = await chrome.storage.local.get("visitedUrls");
  if (!visitedUrls.includes(tab.url)) {
    visitedUrls.push(tab.url);
    await chrome.storage.local.set({ visitedUrls });
  }
});

External links

Exercise

Find any MV2-era Chrome extension on GitHub (search "manifest_version: 2 chrome extension" on github.com — plenty of abandoned hobby projects work). Read its manifest.json and one of its JavaScript files. Identify three MV2 idioms that would need to change for MV3: which manifest fields, which chrome.* API calls, which background pattern. You don't have to migrate it — just write down what you'd change and why, in three short bullet points.
Hint
The dead giveaways are background.scripts + persistent: true in manifest.json, any chrome.extension.* call in the JS, and inline event handlers in HTML files. If the extension uses webRequest blocking, that's a harder migration (rule-based redesign). For a quick win, look for ad-hoc Tampermonkey-style scripts ported to extensions — they almost always have inline JS that violates MV3 CSP.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.