Chrome Extension Event Page Migration — Best Practices
6 min readEvent Page to Service Worker Migration Patterns
This document outlines patterns for migrating Chrome extensions from Manifest V2 event pages to Manifest V3 service workers.
Key Differences Between MV2 Event Pages and MV3 Service Workers
Manifest V3 service workers have significant differences from MV2 event pages:
- No DOM access: Service workers cannot access the DOM directly
- No window object: There is no global
windowobject - No XMLHttpRequest: Use
fetchAPI instead - No persistent state: Variables are not preserved between invocations
- Ephemeral lifecycle: Service workers terminate after idle and restart on events
Global State Migration
MV2 event pages could store state in global variables. MV3 service workers lose all state when terminated.
Before (MV2 Event Page)
// event-page.js - MV2
let cachedData = null;
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'getData') {
if (cachedData) {
sendResponse({ data: cachedData });
} else {
fetchData().then(data => {
cachedData = data;
sendResponse({ data });
});
return true; // Keep message channel open
}
}
});
After (MV3 Service Worker)
// service-worker.js - MV3
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'getData') {
// Always read from chrome.storage
chrome.storage.session.get(['cachedData']).then(result => {
if (result.cachedData) {
sendResponse({ data: result.cachedData });
} else {
fetchData().then(data => {
chrome.storage.session.set({ cachedData: data });
sendResponse({ data });
});
}
});
return true;
}
});
Use chrome.storage.session for ephemeral data or chrome.storage.local for persistent data.
DOM Operations: Offscreen Documents
For operations requiring DOM access (audio, canvas, clipboard), use offscreen documents.
Audio Playback
// service-worker.js
async function playAudio(audioUrl) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['AUDIO_PLAYBACK'],
justification: 'Playing audio notification'
});
// Send message to offscreen document to play audio
const clients = await clients.matchAll();
clients[0].postMessage({ action: 'playAudio', url: audioUrl });
}
Canvas/Image Processing
// service-worker.js
async function processImage(imageData) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['CANVAS'],
justification: 'Processing image data'
});
const clients = await clients.matchAll();
clients[0].postMessage({ action: 'processImage', data: imageData });
}
Replacing setTimeout/setInterval
Service workers cannot rely on setTimeout for delays over 30 seconds. Use chrome.alarms instead.
Before (MV2)
// event-page.js
setTimeout(() => {
doSomething();
}, 60 * 60 * 1000); // 1 hour
After (MV3)
// service-worker.js
chrome.alarms.create('myAlarm', { delayInMinutes: 60 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'myAlarm') {
doSomething();
}
});
XMLHttpRequest to Fetch Migration
Replace deprecated XMLHttpRequest with the fetch API.
Before (MV2)
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data');
xhr.onload = () => console.log(xhr.responseText);
xhr.send();
After (MV3)
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
localStorage to chrome.storage
Replace localStorage with chrome.storage.
Before (MV2)
localStorage.setItem('key', 'value');
const value = localStorage.getItem('key');
After (MV3)
chrome.storage.local.set({ key: 'value' });
chrome.storage.local.get(['key']).then(result => {
const value = result.key;
});
WebSocket in Service Workers
WebSocket connections cannot persist in service workers. Use an offscreen document for persistent connections.
// service-worker.js - delegate to offscreen
async function connectWebSocket(url) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['WEB_RTC'],
justification: 'Maintaining WebSocket connection'
});
const clients = await clients.matchAll();
clients[0].postMessage({ action: 'connectWebSocket', url });
}
Common Migration Mistakes
- Assuming persistent state: Never store data in global variables
- Using setTimeout for long delays: Use chrome.alarms API
- Attempting DOM access: Use offscreen documents or content scripts
- Using localStorage: Always use chrome.storage APIs
- Not handling service worker lifecycle: Plan for termination and restart
Testing Migration
- Run both versions side-by-side to compare behavior
- Test edge cases: idle timeout, memory limits, event ordering
- Verify all chrome.storage operations work correctly
- Test offscreen document lifecycle management
Related Documentation
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.