Chrome Extension Service Workers — Manifest V3 Guide

12 min read

MV3 Service Workers: A Complete Migration Guide

In Manifest V3, the persistent background page from Manifest V2 is replaced by ephemeral service workers. This fundamental architectural change impacts how you manage state, handle events, and structure your extension’s background logic. This guide covers everything you need to know to migrate successfully.


Overview

In MV2, background pages were persistent—they loaded once when the browser started and stayed alive indefinitely. This allowed developers to rely on global variables, maintain DOM references, and use setTimeout/setInterval without concern.

In MV3, background pages are replaced by service workers that are:

This is the single biggest change in MV3 and affects virtually every aspect of background script logic.


Key Differences: MV2 vs MV3

Feature MV2 Background Page MV3 Service Worker
Lifecycle Persistent (always running) Ephemeral (terminate when idle)
DOM Access ✅ Full DOM access ❌ No DOM access
setTimeout/setInterval ✅ Works reliably ⚠️ Terminated; use chrome.alarms
Global State ✅ Stays in memory ❌ Lost on termination
Web APIs Full access Limited (no XMLHttpRequest)
Event Listeners Can be async Must be synchronous (top-level)
Console Access Yes (background page inspectable) Yes (via chrome://extensions)

Manifest Change

The manifest.json configuration changes significantly:

MV2 (Background Scripts)

{
  "background": {
    "scripts": ["background.js"],
    "persistent": true
  }
}

MV3 (Service Worker)

{
  "background": {
    "service_worker": "background.js",
    "type": "module"
  }
}

Key changes:


Problem 1: State Loss

The most critical issue with service workers is that global variables are not preserved between terminations. If your extension relies on:

// ❌ MV2 style - DOES NOT WORK in MV3
let counter = 0;
let userData = {};

chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === 'INCREMENT') {
    counter++;
  }
});

This will fail because when the service worker terminates (after ~30 seconds of inactivity), counter resets to 0.

Solution: Use @theluckystrike/webext-storage

The @theluckystrike/webext-storage library provides a type-safe storage abstraction:

import { defineSchema, createStorage } from "@theluckystrike/webext-storage";

// Define your schema with full type safety
const schema = defineSchema({
  counter: 0,
  lastActiveTab: 0,
  sessionData: {} as Record<string, unknown>
});

// Create the storage instance
const storage = createStorage({ schema });

// Use it anywhere in your service worker
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === 'INCREMENT') {
    storage.set('counter', storage.get('counter') + 1);
  }
  if (msg.type === 'GET_COUNT') {
    return Promise.resolve({ count: storage.get('counter') });
  }
});

Why this works:


Problem 2: setTimeout/setInterval

In MV2, you could set timers that would reliably fire:

// ❌ MV2 style - Unreliable in MV3
setInterval(() => {
  checkForUpdates();
}, 60000); // Every minute

In MV3, the service worker will be terminated before the timer fires, causing missed executions.

Solution: Use chrome.alarms

Chrome provides the chrome.alarms API specifically for this purpose:

import { defineSchema, createStorage } from "@theluckystrike/webext-storage";

const schema = defineSchema({ lastSync: 0 });
const storage = createStorage({ schema });

// Create an alarm
chrome.alarms.create('syncData', {
  periodInMinutes: 5,
  delayInMinutes: 1  // First trigger after 1 minute
});

// Listen for the alarm
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'syncData') {
    syncData();
  }
});

async function syncData() {
  // Your sync logic here
  storage.set('lastSync', Date.now());
}

Benefits of chrome.alarms:


Problem 3: Event Listeners Must Be Synchronous

In MV2, you could register event listeners inside async functions:

// ❌ MV2 style - DOES NOT WORK in MV3
async function setupListeners() {
  // This won't work because by the time the service worker
  // wakes up, this async function may not have run yet
  chrome.runtime.onMessage.addListener(handleMessage);
}

setupListeners();

In MV3, service workers wake up briefly to handle events. If your listener isn’t registered at the top level of the script, it won’t be there when the event fires.

Solution: Use @theluckystrike/webext-messaging

The @theluckystrike/webext-messaging library handles this correctly:

import { createMessenger } from "@theluckystrike/webext-messaging";

// Define your message types
interface Messages = {
  increment: { amount: number };
  getCount: void;
  syncData: void;
};

// Create messenger at top level - synchronous registration
const msg = createMessenger<Messages>();

// Register handlers at top level
msg.onMessage('increment', async ({ amount }) => {
  // Handler logic here
  return { success: true };
});

msg.onMessage('getCount', async () => {
  return { count: 42 };
});

msg.onMessage('syncData', async () => {
  await doSync();
  return { synced: true };
});

Why this works:


Problem 4: No DOM Access

Service workers cannot access the DOM directly. If your background script contained:

// ❌ MV2 style - No DOM in service worker
const div = document.createElement('div');
document.body.appendChild(div);

This will fail in MV3.

Solutions

Option A: Use chrome.offscreen

For operations requiring DOM (like playing audio, WebRTC, etc.), use the Offscreen API:

// Create an offscreen document
await chrome.offscreen.createDocument({
  url: 'offscreen.html',
  reasons: ['DOM_PARSER', 'AUDIO_PLAYBACK'],
  justification: 'Parsing HTML and playing audio notifications'
});

// Communicate via messages
chrome.runtime.sendMessage({
  type: 'DO_DOM_WORK',
  data: { /* ... */ }
});

Option B: Move Logic to Popup or Content Scripts

For most use cases, move DOM-dependent logic to:


Problem 5: No XMLHttpRequest

The XMLHttpRequest API is not available in service workers:

// ❌ MV2 style - Does not work in MV3
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data');
xhr.send();

Solution: Use fetch()

The Fetch API works in service workers:

// ✅ MV3 compatible
async function fetchData() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  return data;
}

Service Worker Lifecycle

Understanding the lifecycle is crucial for debugging and optimization:

┌─────────────────────────────────────────────────────────────────┐
│                      SERVICE WORKER LIFECYCLE                   │
└─────────────────────────────────────────────────────────────────┘

   ┌─────────┐
   │ INSTALL │  ← Cache assets, initialize storage
   └────┬────┘
        │ on install event fires
        ▼
   ┌──────────┐
   │ ACTIVATE │  ← Clean up old caches, handle migrations
   └────┬─────┘
        │ on activate event fires
        ▼
   ┌────────┐     ┌────────┐     ┌───────────┐
   │ IDLE   │────▶│EVENT   │────▶│ TERMINATE │
   └────────┘     └────────┘     └───────────┘
       ▲                                     │
       │                                     │
       └────────── (wake on event) ──────────┘
       
   KEY POINTS:
   - Service worker starts when an event fires
   - After ~30 seconds of inactivity, it terminates
   - On next event, it wakes up fresh (no memory)
   - Use chrome.storage for persistence
   - Register all listeners at top level

Lifecycle Events

  1. Install: Fires once when the service worker first loads
    • Good for one-time setup
    • Precache static assets
  2. Activate: Fires after installation (or when updated)
    • Good for cleaning up old data
    • Handle migrations
  3. Message/Alarm/Event: Wakes the service worker
    • This is when your logic runs
    • Must have all listeners already registered

Migration Checklist

Use this checklist to ensure complete migration:


Common Mistakes

❌ Registering Listeners in Async Functions

// WRONG
async function init() {
  chrome.runtime.onMessage.addListener(handler);
}
init();
// CORRECT - Top level
chrome.runtime.onMessage.addListener(handler);

❌ Relying on Memory for State

// WRONG - State lost on termination
let user = { name: 'John' };
// CORRECT - Use storage
import { defineSchema, createStorage } from "@theluckystrike/webext-storage";
const schema = defineSchema({ user: {} as { name: string } });
const storage = createStorage({ schema });
// State persists across terminations

❌ Using setTimeout for Delayed Tasks

// WRONG - May not fire after termination
setTimeout(doSomething, 60000);
// CORRECT - Use chrome.alarms
chrome.alarms.create('delayedTask', { delayInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'delayedTask') doSomething();
});

❌ Using XMLHttpRequest

// WRONG - Not available in service worker
const xhr = new XMLHttpRequest();
// CORRECT - Use fetch
const response = await fetch(url);
const data = await response.json();

Summary

Migrating from MV2 background pages to MV3 service workers requires rethinking your architecture:

  1. State must live in storage, not memory
  2. Timers must use chrome.alarms, not setTimeout/setInterval
  3. Event listeners must be synchronous and top-level
  4. No DOM in the service worker—use offscreen or other contexts
  5. No XMLHttpRequest—use fetch instead

The @theluckystrike/webext-storage and @theluckystrike/webext-messaging libraries provide the foundation for building reliable MV3 extensions that handle the ephemeral nature of service workers gracefully.


Next Steps

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.