Migrate Chrome Extensions to Manifest V3 in 30 Minutes

Update your Chrome extension from Manifest V2 to V3 with working code examples for service workers, host permissions, and content scripts.

Problem: Your Chrome Extension Stops Working in 2026

Google deprecated Manifest V2 in January 2024 and Chrome now warns users about outdated extensions. Your extension breaks with executeScript errors, background pages fail, and users can't install it from the Chrome Web Store.

You'll learn:

  • How to convert background pages to service workers
  • Fix permission and host_permissions syntax
  • Update content script injection and messaging
  • Handle the new action API

Time: 30 min | Level: Intermediate


Why This Happens

Manifest V3 fundamentally changes Chrome's extension architecture for security and performance. Background pages (persistent scripts) are replaced with service workers (event-driven), and permissions are more granular.

Common symptoms:

  • chrome.browserAction is undefined
  • executeScript requires different permissions
  • Background page stops after 30 seconds
  • "This extension may soon no longer be supported" warning

Solution

Step 1: Update manifest.json Structure

{
  "manifest_version": 3,
  "name": "Your Extension",
  "version": "2.0.0",
  "description": "Updated for Manifest V3",
  
  // V2: "browser_action"
  // V3: "action" (combines browser_action and page_action)
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  
  // V2: "background": { "scripts": ["background.js"], "persistent": false }
  // V3: Service worker only
  "background": {
    "service_worker": "background.js",
    "type": "module"  // Enables ES modules
  },
  
  // V2: Permissions included host patterns
  // V3: Separate host_permissions array
  "permissions": [
    "storage",
    "activeTab",
    "scripting"  // Required for executeScript
  ],
  "host_permissions": [
    "https://*.example.com/*"
  ],
  
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"]
    }
  ]
}

Why this works: V3 separates concerns - action for UI, service workers for background logic, and explicit host_permissions for security transparency.

Expected: No validation errors when loading unpacked extension in chrome://extensions


Step 2: Convert Background Page to Service Worker

Before (V2 background.js):

// Persistent background page - always running
let cachedData = {};

chrome.browserAction.onClicked.addListener((tab) => {
  chrome.tabs.executeScript(tab.id, {
    file: 'content.js'
  });
});

// Direct DOM access worked
document.getElementById('something');

After (V3 background.js):

// Service worker - event-driven, no DOM access
// Use chrome.storage for persistence across wake/sleep cycles

chrome.action.onClicked.addListener(async (tab) => {
  // executeScript moved to chrome.scripting namespace
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: ['content.js']
  });
});

// Store data in chrome.storage instead of variables
chrome.runtime.onInstalled.addListener(async () => {
  await chrome.storage.local.set({ cachedData: {} });
});

// Service workers sleep after 30s - load data when needed
chrome.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
  const { cachedData } = await chrome.storage.local.get('cachedData');
  // Process message with loaded data
});

Critical differences:

  • No document or window access in service workers
  • Variables reset when worker sleeps - use chrome.storage for persistence
  • All Chrome API calls now return Promises (use async/await)
  • chrome.tabs.executeScriptchrome.scripting.executeScript

Step 3: Fix Dynamic Content Script Injection

Before (V2):

chrome.tabs.executeScript(tabId, {
  code: `document.body.style.backgroundColor = 'red';`
});

After (V3):

// Option 1: Execute file
await chrome.scripting.executeScript({
  target: { tabId: tabId },
  files: ['inject.js']
});

// Option 2: Inline code (needs "scripting" permission)
await chrome.scripting.executeScript({
  target: { tabId: tabId },
  func: () => {
    document.body.style.backgroundColor = 'red';
  }
});

// Option 3: Pass arguments to function
await chrome.scripting.executeScript({
  target: { tabId: tabId },
  func: (color) => {
    document.body.style.backgroundColor = color;
  },
  args: ['red']
});

Why this works: V3 requires explicit scripting permission and separates code from execution context for security.

If it fails:

  • Error: "Cannot access chrome://... URLs": Chrome internal pages are blocked, check tab.url first
  • Error: "Cannot access contents of URL": Add URL to host_permissions in manifest

Step 4: Update Messaging Between Scripts

Before (V2):

// Background page
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  sendResponse({ data: 'result' });
  return true; // Keep channel open
});

// Content script
chrome.runtime.sendMessage({ action: 'getData' }, (response) => {
  console.log(response.data);
});

After (V3 - same syntax but async handling):

// background.js (service worker)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  // Async work requires returning true and calling sendResponse later
  handleMessage(msg).then(sendResponse);
  return true; // Keep message channel open for async response
});

async function handleMessage(msg) {
  // Service worker may sleep/wake - load state from storage
  const state = await chrome.storage.local.get('appState');
  return { data: processData(msg, state) };
}

// content.js
const response = await chrome.runtime.sendMessage({ action: 'getData' });
console.log(response.data);

Key change: Message handling works the same, but background service workers must reload state from storage on each message.


Step 5: Handle Service Worker Lifecycle

Service workers sleep after 30 seconds of inactivity. Critical patterns:

// ✅ Good: Load state when needed
chrome.runtime.onMessage.addListener(async (msg) => {
  const data = await chrome.storage.local.get('key');
  // Use data
});

// ⌠Bad: Assuming global variables persist
let cache = {}; // This resets when worker wakes up
chrome.runtime.onMessage.addListener((msg) => {
  // cache is empty after worker sleeps
});

// ✅ Good: Periodic tasks with alarms
chrome.alarms.create('sync', { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === 'sync') {
    await performSync();
  }
});

// ⌠Bad: setInterval (doesn't survive sleep)
setInterval(() => performSync(), 300000); // Won't work

Step 6: Update API Name Changes

Quick reference for renamed APIs:

// V2 → V3 API changes

// Browser action
chrome.browserAction.setIcon()      chrome.action.setIcon()
chrome.browserAction.setBadgeText()  chrome.action.setBadgeText()
chrome.pageAction.show()            chrome.action.show()

// Tab management
chrome.tabs.executeScript()         chrome.scripting.executeScript()
chrome.tabs.insertCSS()             chrome.scripting.insertCSS()

// Web request (now more limited)
chrome.webRequest.onBeforeRequest   Use declarativeNetRequest for blocking

Declarative Net Request example:

// manifest.json
{
  "permissions": ["declarativeNetRequest"],
  "declarative_net_request": {
    "rule_resources": [{
      "id": "ruleset_1",
      "enabled": true,
      "path": "rules.json"
    }]
  }
}
// rules.json - blocks ads declaratively
[
  {
    "id": 1,
    "priority": 1,
    "action": { "type": "block" },
    "condition": {
      "urlFilter": "*://ads.example.com/*",
      "resourceTypes": ["script", "image"]
    }
  }
]

Verification

Test the Migration

# 1. Load unpacked extension in Chrome
# Open chrome://extensions → Enable Developer Mode → Load unpacked

# 2. Check for errors in service worker
# Click "service worker" link in extension card → Check console

# 3. Test critical paths
# - Click extension icon
# - Trigger content script injection
# - Send messages between scripts
# - Check storage persistence after 30s

You should see:

  • No console errors in service worker
  • Content scripts inject successfully
  • Messages send/receive properly
  • Data persists after service worker sleeps

Common Issues Checklist

// Debug service worker lifecycle
chrome.runtime.onSuspend.addListener(() => {
  console.log('Service worker suspending');
});

chrome.runtime.onStartup.addListener(() => {
  console.log('Service worker starting');
});

// Check if storage is persisting
setInterval(async () => {
  const data = await chrome.storage.local.get(null);
  console.log('Current storage:', data);
}, 5000);

What You Learned

  • Service workers replace background pages with event-driven architecture
  • Global variables don't persist - use chrome.storage for state
  • Permissions are now split into permissions and host_permissions
  • Content script injection requires the scripting permission
  • Most Chrome APIs now return Promises instead of callbacks

Limitations:

  • Service workers can't access DOM (no document or window)
  • chrome.webRequest blocking is replaced by declarative rules
  • Background work must complete within 30 seconds or use alarms

Migration complexity:

  • Simple extensions (popup + content scripts): 10-15 minutes
  • Medium complexity (background messaging): 20-30 minutes
  • Complex extensions (webRequest blocking, long-running tasks): 1-2 hours

AI-Assisted Migration Tips

Modern AI tools can accelerate migration:

Use AI for:

// Prompt: "Convert this Manifest V2 background page to V3 service worker"
// Paste your background.js code
// AI identifies: variable persistence issues, API renames, missing permissions

Common AI suggestions to verify:

  • Double-check host_permissions arrays - AI may over-scope
  • Review chrome.storage patterns - ensure minimal read/writes
  • Test service worker sleep/wake cycle - AI can't simulate this
  • Validate declarativeNetRequest rules - complex logic needs testing

AI can't detect:

  • Race conditions in async service worker wake cycles
  • Performance issues from excessive storage reads
  • Edge cases in content script injection timing

Tested on Chrome 132.x, Manifest V3, macOS & Windows 11