alright so you're building a browser extension and everyone's talking about Manifest V3, V4, V5, Vinfinity…
but in firefox world everyone is still using manifest V2, whyyyyyyy and does Firefox support manifest V3?
let’s break it down and understand the difference between the manifest version 3 (chrome) and manifest version 2 (firefox) in browser extensions
One quick thing, i created a chrome extension boilerplate that lets you ship & monetize your extension fast (includes auth, payments, and everything you need). if you are interested, It’s called extFast
What Are Manifests Anyway?
Before diving into the differences, let's quickly cover the basics. Every browser extension needs a manifest.json file, it's like the blueprint that tells the browser what your extension does, what permissions it needs, and name, logo, description etc.
Manifest V2 was introduced in 2012 and has been the standard for over a decade. Manifest V3, announced by Google in 2020, represents a major architectural change that Google claims improves security, privacy, and performance, though i think they introduced V3 and killed V2 because of ad blockers (not really much interesting in new V3, it only s*cks more)
Timeline and Browser Support
Important dates:
- Chrome stopped accepting new Manifest V2 extensions in January 2022
- Chrome Web Store phased out Manifest V2 in June 2024
- Firefox still supports both V2 and V3, with no immediate deprecation plans, source
This means if you're building a new extension for Chrome today, you must use Manifest V3. Firefox developers have more flexibility (you can use Manifest V2, in fact this is a big pain and if you are not using an extension development framework like WXT then you need to manually adjust the extension for firefox v2).
Major Architectural Changes
1. Background Scripts → Service Workers
This is perhaps the biggest change and affects how your extension runs in the background.
Manifest V2: Persistent Background Pages
{
"background": {
"scripts": ["background.js"],
"persistent": true
}
}
In V2, background scripts ran continuously in a hidden page. They had access to the DOM, could use window and document objects, and stayed alive as long as the browser was open.
Manifest V3: Service Workers
{
"background": {
"service_worker": "background.js"
}
}
In V3, background scripts are replaced by service workers that are event-driven and non-persistent. They start when needed (like when you send a message from your content script), handle events, and then shut down after a period of inactivity (typically 10-30 seconds in Chrome).
What this means for you:
-
No DOM access: Service workers don't have access to
window,document, or DOM APIs - No persistent state: Variables don't persist between service worker restarts (like Map() or Set())
- Event-driven: Your code only runs in response to specific events
- Frequent restarts: Your service worker will start and stop many times during a browser session
Migration example:
// ❌ Manifest V2 approach
let userSettings = {}; // This won't persist in V3
chrome.runtime.onMessage.addListener((message) => {
userSettings = message.settings; // Lost when service worker stops
});
// ✅ Manifest V3 approach (use local storage)
chrome.runtime.onMessage.addListener((message) => {
chrome.storage.local.set({ userSettings: message.settings });
});
2. Host Permissions: Critical Browser Differences
This is where Chrome and Firefox differ significantly, and it's important to understand both.
Manifest V2 (Firefox):
{
"permissions": [
"storage",
"tabs",
"https://api.example.com/*"
]
}
In Firefox with V2, you need to declare API endpoints in the permissions array to make API requests to them, other it will throw errors i guess.
Manifest V3 (Chrome):
{
"permissions": [
"storage",
"scripting"
]
}
Here's the crucial difference: In Chrome with Manifest V3, you don't need to declare API endpoints in permissions or host_permissions just to make fetch/API requests to them. Host permissions in Chrome V3 are only required if you want to:
- Inject content scripts into pages, source
- Access cookies from specific domains
Example - Making API Requests:
// ✅ Works in Chrome V3 without any host_permissions
const response = await fetch('https://api.example.com/data');
const data = await response.json();
// ❌ In Firefox V2, you'd need "https://api.example.com/*" in permissions
Chrome V3 is more permissive with outbound API requests, while Firefox V2 requires explicit permission declarations.
4. Web Accessible Resources
V3 requires you to specify which resources can be accessed by web pages.
Manifest V2:
{
"web_accessible_resources": [
"images/*.png"
]
}
Manifest V3:
{
"web_accessible_resources": [
{
"resources": ["images/*.png"],
"matches": ["https://example.com/*"] 👈
}
]
}
This prevents other websites from detecting your extension by trying to access its resources (files).
Offscreen Documents
Since service workers are non-persistent and restart frequently, V3 introduces offscreen documents to handle scenarios where you need persistent state or background processing.
What are offscreen documents?
Offscreen documents are hidden HTML pages that run in a separate context. They're particularly useful for:
- Persistent state that survives service worker restarts
-
Background processing e.g. using the
WORKERSreason - Computationally intensive tasks
- Certain Web APIs that require a document context (like clipboard access)
// Create an offscreen document for background processing
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['WORKERS'],
justification: 'Maintain dictionary lookup cache'
});
// Service worker can communicate with offscreen document
chrome.runtime.sendMessage({
action: 'lookup',
word: 'example'
});
Example: Dictionary Lookup with Persistent State
// offscreen.html script
const dictionaryCache = new Map();
// Process expensive lookups and cache results
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'lookup') {
// Check if we have it cached
if (dictionaryCache.has(message.word)) {
sendResponse({ result: dictionaryCache.get(message.word) });
return;
}
// Perform expensive lookup/processing
const result = performExpensiveLookup(message.word);
// Cache it for future requests
dictionaryCache.set(message.word, result);
sendResponse({ result });
}
return true;
});
function performExpensiveLookup(word: string) {
// Heavy processing that would slow down service worker
// This state persists even when service worker restarts
return { definition: '...', synonyms: ['...'] };
}
The key advantage here is that the new Map() object and its data persist as long as the offscreen document is alive, surviving multiple service worker restarts. This is perfect for caching, maintaining lookup tables, or keeping any state that would be expensive to recreate.
Action API Changes
Manifest V2: Had separate browser_action and page_action
"browser_action": {
"default_title": "your extension name",
"default_popup": "popup.html"
},
Manifest V3: Unified into single action API
{
"action": {
"default_title": "your extension name",
"default_popup": "popup.html",
}
}
Promises Instead of Callbacks
V3 APIs support promises, making async code cleaner.
Manifest V2:
chrome.storage.local.get(['key'], (result) => {
console.log(result.key);
});
Manifest V3:
const result = await chrome.storage.local.get(['key']);
console.log(result.key);
Note: Chrome still supports callbacks for backward compatibility, but promises are preferred.
Complete Manifest Comparison
Here's a side-by-side comparison of a basic manifest file:
Manifest V2:
{
"manifest_version": 2,
"name": "My Extension",
"version": "1.0",
"description": "Extension description",
"permissions": [
"storage",
"tabs",
"https://api.example.com/*"
],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"browser_action": {
"default_popup": "popup.html"
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"]
}]
}
Manifest V3:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0",
"description": "Extension description",
"permissions": [
"storage",
"tabs"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html"
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"]
}]
}
that’s pretty much it,
thankyou soo much for reading 💛
PS: if you want to build & monetize your extension fast, you can checkout my boilerplate: extFast
bye bye :)
Top comments (0)