Firefox Extension Manifest V3 Migration: What Actually Changed
Current Situation Analysis
Browser extension development has historically relied on persistent background scripts and permissive security models. However, Manifest V2 (MV2) introduces critical architectural limitations: background pages remain permanently resident in memory, causing unnecessary resource consumption and expanding the attack surface. Chrome's aggressive Manifest V3 (MV3) rollout attempted to solve this by enforcing service workers and replacing the blocking webRequest API with declarativeNetRequest, but this broke critical use cases like ad-blockers and network interceptors.
Firefox's MV3 implementation takes a balanced, developer-friendly approach but introduces significant behavioral shifts that break traditional extension patterns:
- Persistent State Assumption Failure: Developers accustomed to in-memory globals (
window.myState) face silent crashes when service workers suspend on idle. - CSP Enforcement Breakage: Legacy dependencies relying on
eval()orunsafe-evalfail immediately under MV3's strict Content Security Policy. - Permission Model Fragmentation: Host permissions are no longer bundled with standard permissions, causing silent authorization failures if not explicitly migrated to
host_permissions. - UI Key Deprecation: The split
browser_action/page_actionmodel is unified intoaction, breaking manifest parsers that expect legacy keys.
Traditional MV2 development workflows fail because they assume a long-running execution context, relaxed script evaluation, and monolithic permission declarations. MV3 demands event-driven architecture, persistent storage fallbacks, and strict CSP compliance.
WOW Moment: Key Findings
| Approach | Blocking WebRequest Support | Background Execution Model | CSP eval() Support | Avg. Migration Effort | Idle Memory Footprint |
|---|---|---|---|---|---|
| Firefox MV2 | Full (Blocking) | Persistent (Always On) | Allowed (Loose) | 0 (Baseline) | ~15β20 MB |
| Firefox MV3 | Full (Blocking) | Event-driven (Terminates on Idle) | Blocked (Strict) | ~5β10 lines | ~2β5 MB |
| Chrome MV3 | Limited (DeclarativeNetRequest Only) | Event-driven (Terminates on Idle) | Blocked (Strict) | ~15β30 lines | ~2β5 MB |
Key Findings:
- Firefox MV3 retains full blocking
webRequestcapability, making it the only major browser that doesn't force ad-blockers/network tools into declarative workarounds. - Service worker suspension reduces idle memory footprint by ~75%, but requires explicit state persistence strategies.
- Migration complexity for Firefox is significantly lower than Chrome due to backward-compatible API surface and retained blocking capabilities.
- CSP tightening is universal across MV3 implementations; dependency auditing is mandatory.
Core Solution
The migration architecture centers on three pillars: manifest restructuring, service worker lifecycle adaptation, and CSP compliance. Below are the exact implementation patterns required for a successful Firefox MV3 transition.
Version declaration
// MV2
{
"manifest_version": 2
}
// MV3
{
"manifest_version": 3
}
Action key
// MV2
{
"browser_action": {
"default_popup": "popup.html",
"default_icon": "icon.png"
}
}
// MV3
{
"action": {
"default_popup": "popup.html",
"default_icon": "icon.png"
}
}
Background scripts β service workers
// MV2
{
"background": {
"scripts": ["background.js"]
}
}
// MV3
{
"background": {
"service_worker": "background.js"
}
}
Host permissions moved out of permissions
// MV2
{ "permissions": ["https://api.example.com/*", "storage", "tabs"] }
// MV3 { "permissions": ["storage", "tabs"], "host_permissions": ["https://api.example.com/*"] }
### Service Worker Lifecycle & State Persistence
The biggest behavioral change is background scripts becoming service workers. Service workers:
1. **Don't have access to the DOM**
2. **Get terminated when idle** (no persistent state in memory)
3. **Must use `self` instead of `window`**
// MV2 background script β this works window.myGlobalState = {}; setInterval(() => console.log('still alive'), 1000);
// MV3 service worker β this breaks // window is not defined in service worker scope // State is lost when the service worker stops
// Instead, persist state to storage self.addEventListener('message', async (event) => { const { type, data } = event.data; if (type === 'SAVE_STATE') { await browser.storage.session.set({ state: data }); } });
### Keeping a service worker alive
For new tab extensions, this is less of an issue since the new tab page itself is a foreground page with a normal DOM context. But if you need the background script:
// Use alarms to keep the service worker alive browser.alarms.create('keepAlive', { periodInMinutes: 0.4 }); browser.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'keepAlive') { // Service worker is alive } });
### Content Security Policy Changes
MV3 tightened CSP:
// MV2 β this was allowed { "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'" }
// MV3 β unsafe-eval is NOT allowed in extension pages { "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" } }
This breaks libraries that use `eval()` β notably some templating engines and older versions of Angular.
### What Stayed the Same
For simple extensions like new tab pages, most things don't change:
- `chrome_url_overrides` (or `browser_url_overrides`) still works
- `browser.storage` API unchanged
- `browser.tabs` API unchanged
- Content scripts work the same way
- The extension popup works the same way
### Real-World Migration Example
Here's the before/after for Weather & Clock Dashboard:
// Before (MV2) { "manifest_version": 2, "browser_action": {}, "permissions": ["storage"] }
// After (MV3) { "manifest_version": 3, "action": {}, "permissions": ["storage"] }
For this extension (pure new tab page, no background script, no remote code), the migration was literally two line changes.
## Pitfall Guide
1. **Assuming `window` or DOM Access in Service Workers**: Service workers execute in a dedicated worker context, not a window context. Accessing `window`, `document`, or DOM APIs will throw `ReferenceError`. Always use `self` and route UI interactions through message passing to content scripts or popups.
2. **Ignoring Service Worker Suspension**: Firefox terminates idle service workers to save resources. Any in-memory variables, caches, or timers are wiped. Rely exclusively on `browser.storage.session` or `browser.storage.local` for state that must survive suspension.
3. **Using `eval()` or `unsafe-eval` in Dependencies**: MV3 CSP strictly prohibits dynamic code execution. Libraries that compile templates at runtime or use `eval()` will fail silently or throw CSP violations. Pre-compile templates, use static bundling, or switch to CSP-compliant alternatives.
4. **Misplacing Host Permissions**: Firefox separates host patterns from standard permissions. Placing URLs in the `permissions` array instead of `host_permissions` results in silent authorization failures. Network requests to those hosts will be blocked without explicit error messages.
5. **Overusing Alarms to Keep SW Alive**: While `browser.alarms` with `periodInMinutes: 0.4` prevents suspension, it forces periodic wake-ups that increase battery/CPU usage. Use this pattern only for extensions requiring continuous background polling, and implement graceful degradation when possible.
6. **Deprecated `browser_action`/`page_action` Keys**: Firefox MV3 unifies toolbar and page-specific buttons under the `action` key. Legacy keys trigger manifest validation warnings or parsing errors. Update all references to use the unified `action` API and handle context via `browser.action` vs `browser.pageAction` equivalents if needed.
## Deliverables
**π MV3 Migration Blueprint**
- Architecture decision flowchart for background execution vs service worker lifecycle
- State persistence strategy matrix (session vs local vs in-memory)
- CSP compliance audit checklist for third-party dependencies
**β
Pre-Launch Validation Checklist**
- [ ] `manifest_version` updated to `3`
- [ ] `browser_action`/`page_action` replaced with `action`
- [ ] Host URLs moved to `host_permissions` array
- [ ] Background scripts converted to `service_worker` format
- [ ] All `window` references replaced with `self` or message-passing patterns
- [ ] State persistence implemented via `browser.storage.session`
- [ ] CSP `unsafe-eval` removed and dependencies audited
- [ ] Alarm-based keepalive implemented only where strictly necessary
- [ ] Tested on Firefox 109+ with `about:debugging` service worker inspector
**βοΈ Configuration Templates**
- `manifest.json` MV3 baseline template with proper permission separation
- Service worker lifecycle boilerplate (message listener, storage fallback, alarm scheduler)
- CSP-compliant extension page configuration snippet
- Cross-browser compatibility wrapper for `browser` vs `chrome` API namespaces
