Moving from Background Pages to Service Workers in Manifest V3

Migrating a Chrome extension from Manifest V2 background pages to Manifest V3 service workers is not a search-and-replace. Background pages in MV2 are persistent — they stay alive as long as the browser is running and can maintain in-memory state, open ports, and long-running async operations indefinitely. Service workers in MV3 are event-driven and ephemeral — they start when needed, handle one or more events, and stop when idle. Assumptions baked into MV2 background page code will break in MV3, and the migration requires rethinking those assumptions, not just updating manifest keys.

The Manifest Change

In manifest.json:

// MV2
{
  "manifest_version": 2,
  "background": {
    "scripts": ["background.js"],
    "persistent": false
  }
}

// MV3
{
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js",
    "type": "module"  // optional, enables ES modules in the service worker
  }
}

The type: "module" flag lets you use import/export in the service worker and split background logic across files.

Understanding Service Worker Lifecycle

A service worker can be stopped by Chrome at any time after it has been idle for approximately 30 seconds (the exact timeout is not guaranteed). It restarts when:

  • An extension event fires (an alarm, a network request matching a webRequest listener, a message, a browser action click)
  • chrome.runtime.sendMessage is sent to the extension from a content script or another context

When the service worker restarts, it runs from the top of the file. Variables are reset. Any state that wasn’t persisted to chrome.storage is gone.

The practical consequence: code written for a persistent background page that keeps state in module-level variables will silently lose state in MV3 every time the service worker stops.

What Breaks: In-Memory State

// MV2 background page: this works
let userPreferences = null;  // loaded once at startup

chrome.runtime.onInstalled.addListener(async () => {
  userPreferences = await loadPreferences();
});

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  sendResponse(userPreferences.theme);  // always available
});

In MV3, userPreferences is null after every service worker restart. The listener fires, userPreferences is null, and the response is wrong.

Fix: initialize on first use, not on startup:

// MV3 service worker: lazy initialization
let userPreferences = null;

async function getPreferences() {
  if (!userPreferences) {
    userPreferences = await loadPreferences();
  }
  return userPreferences;
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  getPreferences().then(prefs => sendResponse(prefs.theme));
  return true;  // async response
});

Or store preferences in chrome.storage and read from there on every access:

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  chrome.storage.local.get('preferences', ({ preferences }) => {
    sendResponse(preferences?.theme);
  });
  return true;
});

chrome.storage.session (added in Chrome 102) is useful for state that should persist across service worker restarts within a browser session but not across browser restarts — it’s in-memory persistence that survives worker termination.

What Breaks: Long-Running Timers

// MV2: this works — background page lives indefinitely
setInterval(() => { checkForUpdates(); }, 5 * 60 * 1000);

In MV3, setInterval in a service worker only fires while the worker is running. If the worker stops, the interval is cleared. When the worker restarts, the interval starts over.

Fix: use alarms instead of setInterval:

// MV3: alarms persist across service worker restarts
chrome.alarms.create('checkUpdates', { periodInMinutes: 5 });

chrome.alarms.onAlarm.addListener(alarm => {
  if (alarm.name === 'checkUpdates') {
    checkForUpdates();
  }
});

chrome.alarms survives service worker stops because the alarm schedule is maintained by Chrome, not the worker. The alarm fires, Chrome starts the worker, the worker handles it and stops again.

Note: alarms only fire once per minute minimum. For more frequent work, you need a different approach (though Chrome’s 30-second idle timeout means sub-minute polls often survive if the worker stays active).

What Breaks: WebSocket and Long-Lived Connections

MV2 background pages could maintain persistent WebSocket connections. MV3 service workers cannot — the connection closes when the worker stops.

The options:

  1. Move real-time connections to a content script or popup page that the user has open, and use message passing to the service worker only for actions
  2. Reconnect the WebSocket every time the service worker starts, and implement the reconnection logic in the worker so it handles the connect → use → stop → restart → reconnect cycle

Chrome has an open issue about this limitation. The reality is that persistent connections in background workers are architecturally incompatible with the service worker model.

What Breaks: Calling chrome.runtime.getBackgroundPage()

MV2 popup and options pages could call chrome.runtime.getBackgroundPage() to get a direct reference to the background page and call its functions. This was a convenient tight coupling mechanism.

In MV3, there is no background page object to get. Replace all getBackgroundPage() calls with chrome.runtime.sendMessage() instead:

// MV2 popup
const bg = await chrome.runtime.getBackgroundPage();
const result = bg.doSomething();

// MV3 popup
const result = await chrome.runtime.sendMessage({ type: 'DO_SOMETHING' });

API Changes Beyond the Service Worker

Several Chrome APIs changed in MV3 beyond the background worker model:

chrome.action replaces chrome.browserAction and chrome.pageAction. The manifest action key replaces the old browser_action and page_action keys. The API is similar.

chrome.scripting replaces some uses of chrome.tabs.executeScript. Injecting scripts into tabs now goes through chrome.scripting.executeScript(), which requires the scripting permission.

declarativeNetRequest replaces webRequest blocking. This is the major content-blocking change, covered in detail in the Firefox MV3 differences article.

Remote code execution is banned. eval(), new Function(), and fetching scripts from external URLs for execution are no longer allowed in extension contexts.

The Migration Checklist

Before you start:

  • Audit all module-level variables that store state. Plan how each will be persisted or re-initialized.
  • Find all setInterval/setTimeout calls. Replace with chrome.alarms where appropriate.
  • Find all getBackgroundPage() calls. Replace with messages.
  • Find any WebSocket or long-lived connections. Redesign the architecture.

Manifest changes:

  • Update manifest_version to 3
  • Change background.scripts to background.service_worker
  • Change browser_action to action
  • Update content_security_policy format (MV3 uses an object, MV2 uses a string)
  • Replace chrome.tabs.executeScript with chrome.scripting.executeScript and add scripting permission

Code changes:

  • Add lazy initialization for any state loaded at startup
  • Add chrome.storage.session for session-only ephemeral state
  • Replace all setInterval-based periodic work with chrome.alarms
  • Add return true to all onMessage listeners that respond asynchronously (this was true in MV2 too, but more bugs surface in MV3)

FAQ

How do I keep the service worker alive during testing? Open the service worker DevTools from chrome://extensions (click “service worker” under your extension). The worker stays alive as long as the DevTools window is open. This is for development/testing only — production code must not assume the worker is alive.

What’s the difference between chrome.storage.session and module-level variables? Module-level variables are cleared when the service worker stops. chrome.storage.session persists until the browser closes. Session storage survives the worker being stopped and restarted within the same browser session.

Can I still use Promises in MV3 service workers? Yes. Chrome’s extension APIs support Promises natively in MV3. async/await works throughout. The only caveat is with onMessage.addListener, where you can’t use async as the listener directly — use an async inner function and return true as shown in the messaging guide.

Does Firefox have the same service worker restrictions? Firefox’s MV3 implementation supports non-persistent background pages in addition to service workers, giving more flexibility. The service worker lifecycle restrictions (stops after idle) apply to Firefox service workers too, but Firefox’s background page alternative avoids the problem entirely for extensions that need persistent state.