DEV Community

Projekta2
Projekta2

Posted on

Migrating a Chrome extension from MV2 to MV3: what broke, how I fixed it, and what I'd do differently

Google's Manifest V3 deadline is here. If you have a Chrome extension still running on MV2, you're on borrowed time.

I migrated PR Focus (and two other extensions) from MV2 to MV3. Nothing broke catastrophically — but several things surprised me.

Here's what I learned, so you don't have to figure it out the hard way.


The big changes

1. Background pages → Service workers

This is the biggest shift. In MV2, background scripts run continuously. In MV3, they're service workers that sleep when idle.

What broke:

  • Persistent connections (like WebSockets) needed reconnection logic.
  • Timers and intervals didn't survive sleep cycles.
  • Global state wasn't preserved between events.

How I fixed it:

  • Replaced setInterval with chrome.alarms (which wakes the service worker).
  • Stored state in chrome.storage.local instead of in-memory variables.
  • Added reconnection logic for WebSocket connections.

Code example:


javascript
// MV2 — background script (runs forever)
setInterval(() => {
  checkForNewPRs();
}, 60000);

// MV3 — service worker (can sleep)
chrome.alarms.create('checkPRs', { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'checkPRs') {
    checkForNewPRs();
  }
});

2. Remote code execution is dead
MV3 completely bans remote code execution. No more eval(), new Function(), or loading scripts from external URLs.

What broke:

Dynamic script injection (if you were using it).

Remote code fetching (like loading a script from a CDN).

How I fixed it:

Bundled everything locally.

Replaced dynamic evaluation with explicit imports.

Used chrome.scripting.executeScript() with local files instead of stringified code.

3. Host permissions are now optional
MV2 required all permissions up front. MV3 lets you request permissions at runtime.

What changed:

Users can install without granting all permissions.

You can request sensitive permissions (like tabs or activeTab) only when needed.

How I used it:

PR Focus only requests activeTab when the user opens the popup.

No scary permission prompts at install time.

4. WebRequest blocking is gone
MV3 removed blocking capabilities from webRequest API. You can't intercept and modify requests anymore.

What broke:

Ad blockers and request-modifying extensions.

How I worked around it:

PR Focus doesn't need this API, so no change required. But if you're using it, you'll need declarativeNetRequest instead — which is less powerful and more restrictive.

What I'd do differently
1. Test with the service worker lifecycle earlier
The service worker sleep behavior is hard to debug. I spent days chasing issues that only appeared after 5 minutes of inactivity. Lesson learned: test the idle behavior early.

2. Use chrome.storage.local for everything
MV2 let me rely on in-memory state. MV3 doesn't. I should have moved all state to storage.local from day one. Now I have a mix of both, and it's messy.

3. Plan for the permission model
MV3's runtime permission model is a feature, not a bug. I should have designed PR Focus with optional permissions from the start. Instead, I had to refactor.

The migration checklist
Here's what I'd recommend for any MV2→MV3 migration:

Replace background.scripts with background.service_worker in manifest.json.

Convert background scripts to service workers (no window, no document).

Move all state to chrome.storage.local.

Replace setInterval/setTimeout with chrome.alarms.

Remove all eval() and new Function() calls.

Test service worker idle behavior.

Move permissions to optional where possible.

Use chrome.scripting instead of chrome.tabs.executeScript.

Update host_permissions to match the new model.

Final thoughts
MV3 isn't as bad as the early complaints suggested. The service worker model is better for performance and battery life. The permission model is better for privacy.

But the migration takes time. Don't wait until the last minute. Start now.

If you're migrating a Chrome extension and get stuck, I'm happy to help. [Drop me an issue on GitHub.](https://github.com/projekta2/pr-focus-landing/issues)



Enter fullscreen mode Exit fullscreen mode

Top comments (6)

Collapse
 
alexshev profile image
Alex Shev

MV3 migrations are useful to write up because they change the mental model, not only the API calls. Background behavior, permissions, persistence, and review constraints all become architecture decisions.

Collapse
 
projekta2 profile image
Projekta2

Exactly — you've hit the core of it. The API changes are the easy part. The hard part is retraining your brain to think in terms of:

  • Ephemeral state (service workers sleep, so state can't live in memory)
  • Event-driven architecture (alarms instead of intervals, messages instead of persistent connections)
  • Permission boundaries (optional permissions mean designing for a "least privilege" model)

The service worker lifecycle is the one that caught me off guard the most. I spent a day debugging an issue that only appeared after 5 minutes of inactivity — turns out the service worker had been terminated and my WebSocket connection never reconnected.

Any specific part of the migration that's been the trickiest for you? Always curious to hear what others struggled with.

Collapse
 
alexshev profile image
Alex Shev

The service worker lifecycle is usually the trap I would watch first. Anything that assumes memory, a stable connection, or a long-lived process becomes suspicious in MV3. I would test idle recovery early: alarms, reconnects, queued work, and whether state survives the browser doing exactly what it is allowed to do.

Thread Thread
 
projekta2 profile image
Projekta2

That's a really good point — and exactly the kind of thing that's easy to miss until it's already causing issues.

What I'd add to that: testing idle recovery isn't just about "does it work" — it's about "does it work fast enough". If your service worker takes 3-4 seconds to spin up and reconnect, that's noticeable to the user. I ended up adding a small loading state in the popup to signal that the worker is waking up.

Also worth checking: if you're using chrome.storage.local to persist state, make sure you're not writing to it too frequently in the worker's idle path. I had a bug where the worker was flushing state on every onAlarm event, which caused unnecessary wake-ups and slowed things down.

Curious — have you run into any edge cases with chrome.alarms and multiple tabs? I had a situation where each tab would trigger its own alarm, leading to duplicate checks.

Collapse
 
nazar-boyko profile image
Nazar Boyko

One thing worth flagging for anyone copying the alarms swap. chrome.alarms has a minimum interval of around a minute, so if your old setInterval was running faster than that, moving to alarms quietly slows your polling down instead of matching it. Your once-a-minute PR check sits right at the floor so it's fine, but it's an easy trap for someone who was checking every few seconds. For the cases that genuinely need faster than a minute, the usual escape is keeping the worker awake with a port connection or a chained alarm, and neither is pretty. Good call writing this up before the deadline rush, the alarms gotcha alone saves people a confusing afternoon.

Collapse
 
projekta2 profile image
Projekta2

Great flag — this is one of those "read the docs carefully" moments that's easy to miss.

For anyone reading: chrome.alarms has a minimum interval of about 60 seconds (it varies slightly by browser). So if you were relying on setInterval for sub-minute polling (e.g., checking for new PRs every 10 seconds), the naive swap to alarms will break your expectations.

Workarounds I've seen (and used):

  1. Keep the worker alive with a long-lived port connection (e.g., from a content script or popup). This prevents the worker from sleeping, but defeats the purpose of the sleep model.
  2. Chain alarms — schedule the next alarm immediately after the previous one fires, effectively creating a continuous loop. This works but can be resource-intensive.
  3. Rethink the polling interval — for most use cases, checking every minute is actually fine. Do you really need sub-minute updates?

For PR Focus, I ended up going with option 3. Most PRs don't change that fast, and the 60-second floor actually saved me from over-fetching the GitHub API.

Have you found any elegant ways to get sub-minute intervals in MV3? I'd love to hear if anyone's cracked this without resorting to hacky workarounds.