DUSKELStart a project
7 min read

Chrome MV3 extension patterns that survive updates

Manifest V3 killed the persistent background page and broke a decade of habits. Here are the Chrome extension development patterns that hold up.

Manifest V3 didn't just change some APIs. It changed the model your extension runs under, and most of the pain in Chrome extension development today comes from code written for the old model limping along in the new one. The background page that used to sit there holding state forever is gone. In its place is a service worker that Chrome will kill whenever it feels like it, often within thirty seconds of going idle.

If you internalize that one fact — your background context is disposable and will die mid-task — most MV3 patterns follow from it. If you don't, you will ship an extension that works on your machine and fails silently for a third of your users. Here is what actually survives.

The service worker dies silently — stop storing state in it

The number one MV3 bug is a global variable in the service worker that holds something important — a counter, a cache, a logged-in flag. It works right up until Chrome terminates the worker to reclaim memory, which it does aggressively and without warning. The worker spins back up on the next event with every global reset to its initial value, and your state is gone. No error, no crash, just wrong behavior.

Treat the service worker as stateless. Anything that must persist between events belongs in storage, not in a variable. Read it at the top of the handler, write it back before you finish. And know that even the events themselves don't keep the worker alive — an async operation that outlives the event that triggered it can be cut off when Chrome decides the worker is idle. Keep handlers short, persist eagerly, and never assume the worker you're running in will still exist a moment from now.

Message passing that doesn't drop replies

Messaging between content scripts, the service worker, and the popup is where MV3 extensions get flaky. The classic mistake: an async message handler that forgets to return true. If you're going to call sendResponse after an await, you must return true synchronously from the listener to tell Chrome to keep the message channel open. Forget it and the channel closes, the response never arrives, and the sender hangs on a promise that never resolves.

Expect messages to fail, too. If the service worker was asleep and is still waking up, or the receiving tab navigated away, your send throws or the callback gets a runtime error. Wrap sends in error handling and be ready to retry or degrade. And do not try to message a content script into a tab that may not have one — a chrome:// page, the web store, a tab loaded before your extension installed. Check, or inject the script on demand, before you assume a listener is there.

Storage is your source of truth

Because the worker is disposable, chrome.storage becomes the backbone of the extension, not just a settings bucket. Use storage.local for anything sizable and storage.session for state that should live only as long as the browser is open but must survive worker restarts — that second one is a genuinely useful MV3 addition people forget exists.

Watch two traps. Storage is asynchronous, so races between a content script and the service worker writing the same key are real; design keys so writers don't stomp each other, or funnel writes through one place. And storage.sync has small per-item and total quotas — treat it for genuine user preferences only, and never as a general cache, or you will hit the quota and start losing writes silently.

Content scripts and the migration you keep postponing

Content scripts survived MV3 largely intact, but the ground under them still shifts: a site ships a redesign and your selectors evaporate. Isolate every DOM assumption behind a small layer, fail gracefully when an element isn't found instead of throwing and killing the rest of your script, and use a MutationObserver rather than a fixed timeout when you're waiting for something to render. If you inject UI, remember it shares the page's world and its CSS — scope your styles hard so the host page can't bleed into them and you can't bleed into it.

On the migration itself: the biggest lift is remote code and blocking network interception. MV3 forbids executing remotely hosted code — everything must ship in the package — and the old blocking webRequest API is replaced by declarativeNetRequest, where you hand Chrome declarative rules instead of running your own interception logic. If your extension modifies requests or ran hosted scripts, that's the rewrite, and it's not mechanical. Budget for it as real engineering rather than a find-and-replace.

Test on a cold worker

Nearly every MV3 bug hides because you test with the service worker warm — you just clicked something, so it's alive and its globals are populated. Your users don't arrive that way. In the extensions page you can stop the service worker by hand and then trigger your flow; that cold-start path is where the state-in-a-global bugs surface. Make cold-worker testing part of the routine and most of the silent failures never reach production.

Shipping or migrating a Chrome extension? We've done the MV3 slog.