DUSKELStart a project
9 min read

Migrating a Chrome extension from Manifest V2 to V3

Manifest V2 is dead and V3 breaks assumptions your extension was built on. Here is what actually breaks, why, and how to migrate it without shipping a regression.

If you have a Chrome extension still on Manifest V2, the clock already ran out. Chrome disables V2 extensions, the Web Store stopped accepting new V2 submissions long ago, and enterprise policy exemptions that kept some alive are gone too. This is not a "should we plan for it" conversation anymore; it is a "your extension either got migrated or it stopped working" one.

The frustrating part is that V3 is not a cleaner version of the same model. It changed the runtime assumptions your extension was written against. I have shipped V3 migrations that looked like a config change on paper and turned into a rewrite of how the extension holds state, so let me walk through what actually breaks and how to move it across without introducing bugs your users find before you do.

The one change everything else follows from

In V2 you had a background page. It was an invisible HTML page that stayed alive for as long as the browser was open. You could set a variable on it, open a WebSocket, start a timer, cache a value, and trust it would all still be there ten minutes later. Extensions leaned on that hard, usually without anyone deciding to — it was just how things worked.

V3 replaces the background page with a service worker, and the service worker is ephemeral. Chrome starts it when there is an event to handle and kills it shortly after, often within thirty seconds of going idle. Every migration headache below is a consequence of this single fact: the thing you used to treat as always-on is now something that wakes up, does one job, and dies. If you internalise nothing else, internalise that the background context is no longer allowed to remember anything on its own.

What actually breaks

Persistent state is the first casualty. Any global variable, in-memory cache, or object you set on the background page and expected to read later is gone the moment the worker sleeps. Counters reset, login state evaporates, and the "it works on my machine" demo passes because your worker never idled long enough to die. This is the bug that ships silently. Everything you kept in memory has to move to chrome.storage, and every read of it has to assume it might be starting from cold.

Timers are the second. setTimeout and setInterval do not survive the worker being killed, so anything you scheduled for later may simply never fire. Long-running intervals, polling loops, and "do this in five minutes" logic all break. The replacement is the alarms API, which asks Chrome to wake your worker at a time you specify — the scheduling lives outside your code because your code is not guaranteed to be running when the moment arrives.

Blocking webRequest is the third and the most disruptive if you relied on it. In V2 you could intercept a network request and modify or cancel it in your own JavaScript. V3 removes that blocking power for most extensions and replaces it with declarativeNetRequest, where you declare rules up front and Chrome enforces them without running your code per request. Ad blockers, header rewriters, and request filters all have to be rewritten as static or dynamic rule sets. If your extension's whole reason to exist was inspecting requests in code, this is not a port, it is a redesign, and you need to check your logic even fits the declarative model before promising a date.

Two smaller ones catch people out. Remotely hosted code is banned — you can no longer pull JavaScript from a server and eval it, so anything that loaded logic at runtime has to ship inside the package. And executeScript now injects functions or files rather than arbitrary code strings, so any string-based script injection has to be rewritten.

How to migrate, step by step

Start by auditing, not editing. Read your manifest and list every permission, every background responsibility, and every place you assumed the background page persisted. Grep for setTimeout, setInterval, global state on the background, webRequest listeners, and any runtime code loading. That list is your actual scope. The manifest version bump is the last thing you do, not the first.

Convert the manifest next: manifest_version to 3, background page to a service worker entry, browser_action and page_action collapse into action, and host permissions move into their own field. This part is mechanical and fast, and it is also the part that fools people into thinking the job is nearly done. It is not; it is the part that makes the real problems compile.

Now move state out of memory. Every value the background used to hold goes into chrome.storage, and every function that read those values becomes async and defensive about the store being empty on a cold start. Rewrite timers onto the alarms API. Rewrite webRequest logic as declarativeNetRequest rules, and if it does not map cleanly, that is a signal to redesign that feature rather than force it. Replace any remote code with bundled code and any string injection with function or file injection.

Message passing needs a second look too. Because the worker can die mid-conversation, long-lived connections and multi-step message flows have to tolerate the other end disappearing and reconnecting. Assume any port can drop and any handler can be running in a freshly-woken worker with nothing in memory.

Testing and getting back through review

The trap in testing V3 is that a healthy worker hides your bugs. During active use the worker stays warm, your in-flight state survives, and everything looks fine. You have to force the failure: let the worker idle until Chrome kills it, or stop it by hand in the extension's service worker inspector, then interact and confirm nothing lost its place. If your extension still works after you have manually killed the worker between every action, the state migration is real. If it only works when you click quickly, it is not.

Then re-test the rewritten features specifically. Confirm alarms fire after the worker has slept, confirm your declarativeNetRequest rules actually match the requests the old code caught, and confirm cold-start reads from storage behave when the store is empty. These are the three areas where a migration ships a regression, so they get deliberate tests rather than a quick click-through.

Finally, expect the Web Store to look again. A V3 resubmission is a fresh review, and a changed permission set or a new host permission can trigger manual review that takes days. Keep the permission list as narrow as the new APIs allow — declarativeNetRequest often lets you drop the broad host access V2 needed, which can make your V3 version review faster than the original. Migrate early, submit with room before any deadline you care about, and do not assume approval is instant just because the extension already existed.

Stuck on a V2 extension that needs to move to V3? We do exactly this.