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.browserActionis undefinedexecuteScriptrequires 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
documentorwindowaccess in service workers - Variables reset when worker sleeps - use
chrome.storagefor persistence - All Chrome API calls now return Promises (use
async/await) chrome.tabs.executeScript→chrome.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.urlfirst - Error: "Cannot access contents of URL": Add URL to
host_permissionsin 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.storagefor state - Permissions are now split into
permissionsandhost_permissions - Content script injection requires the
scriptingpermission - Most Chrome APIs now return Promises instead of callbacks
Limitations:
- Service workers can't access DOM (no
documentorwindow) chrome.webRequestblocking 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_permissionsarrays - AI may over-scope - Review
chrome.storagepatterns - ensure minimal read/writes - Test service worker sleep/wake cycle - AI can't simulate this
- Validate
declarativeNetRequestrules - 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