I built three Chrome extensions natively on Manifest V3. Not ported — built from scratch knowing MV3 was the target. Here's what that actually means in practice.
This isn't a "MV3 is great/terrible" opinion piece. It's a list of concrete decisions I had to make and bugs I had to debug, with the code that came out the other side.
The problem MV3 solves (and why it matters to you even if you don't care about Chrome's reasons)
MV2 extensions could run persistent background scripts that had unrestricted access to network requests. This is how ad blockers worked — and it's also how malicious extensions worked. Chrome's argument for MV3 is that restricting background script behavior makes extensions less dangerous.
Whether you agree with that tradeoff or not, it's done. MV2 extensions are gone from the Web Store. If you're building extensions now, you're building on MV3.
The practical consequence: your background logic no longer runs in a persistent process. It runs in a Service Worker that Chrome can terminate at any moment, then restart when it needs to handle an event.
This changes everything about how you architect state.
Change #1: Service Workers kill your global state
In MV2, you had background.js. It ran. It had variables. Those variables were always there.
// MV2 background.js — this just worked
let userSettings = {};
let tabTimers = {};
chrome.tabs.onActivated.addListener((activeInfo) => {
// tabTimers is always available here
startTimer(activeInfo.tabId);
});
In MV3, the Service Worker can die between events. That tabTimers object you built up over the last hour? Gone if Chrome decided to reclaim memory.
The fix: chrome.storage is your only reliable persistence layer.
// MV3 — everything that needs to survive goes to storage
async function startTimer(tabId) {
const { tabTimers = {} } = await chrome.storage.local.get('tabTimers');
tabTimers[tabId] = tabTimers[tabId] || { start: Date.now(), elapsed: 0 };
await chrome.storage.local.set({ tabTimers });
}
The cost: async everywhere. chrome.storage operations return Promises. If you're used to synchronous state access, this requires rethinking your code's structure, not just a search-and-replace.
The hidden benefit: This discipline makes your extension more resilient. If the browser crashes and restarts, your state survives. You get crash recovery for free.
Change #2: setInterval in background is unreliable — use Alarms
setInterval in a MV2 background page: works perfectly, runs every N seconds indefinitely.
setInterval in a MV3 Service Worker: runs until Chrome decides to kill the SW. Could be 30 seconds. Could be 5 minutes. Not predictable.
For TabCost Pro, I need to update the cost counter periodically. This is exactly the problem chrome.alarms solves:
// Register once — survives SW restarts
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('costTick', { periodInMinutes: 1 });
});
// Handle in the SW — Chrome wakes the SW for this
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'costTick') {
await updateAllTabCosts();
}
});
Critical limitation: The minimum alarm period in MV3 is 1 minute. If you need sub-minute updates (like a cost counter ticking in near-real-time), you need a different approach for when the popup is open.
My solution for TabCost: the popup runs its own setInterval when it's visible (popups have their own lifecycle, they can use intervals while open), and the alarm handles persistence when the popup is closed.
// In popup.js — runs only while popup is open
let localInterval;
document.addEventListener('DOMContentLoaded', () => {
localInterval = setInterval(updateDisplay, 1000); // Updates every second
loadFromStorage(); // Initialize from persisted state
});
window.addEventListener('unload', () => {
clearInterval(localInterval);
persistCurrentState(); // Save before popup closes
});
Change #3: IndexedDB for large datasets
chrome.storage.local has a 10MB limit (configurable with unlimitedStorage permission, but that requires justification to the Chrome Web Store). For PR Focus, storing summaries and risk scores for 100+ PRs can easily exceed that.
IndexedDB is the answer. It can handle gigabytes, it's transactional, and it's accessible from both the popup and content scripts.
The catch: IndexedDB access from Service Workers was historically unreliable (Chrome had bugs here until around Chrome 102). Check your minimum Chrome version and test explicitly.
// db.js — shared database module
const DB_NAME = 'PRFocusDB';
const DB_VERSION = 1;
export function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('prData')) {
const store = db.createObjectStore('prData', { keyPath: 'id' });
store.createIndex('repo', 'repo', { unique: false });
store.createIndex('riskScore', 'riskScore', { unique: false });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
Change #4: No eval(), no remote scripts, stricter CSP
MV3 prohibits executing dynamically evaluated code from remote sources. If your extension was loading a script from a CDN and running it, that's over.
The practical impact on my code: I had some HTML generation using template literals assigned to innerHTML:
// ❌ This works but is bad practice in MV3 context
container.innerHTML = `
<div class="pr-card" data-id="${pr.id}">
<h3>${pr.title}</h3>
<span class="score">${pr.score}</span>
</div>
`;
The problem isn't innerHTML itself (it's not eval), but if pr.title contains HTML, you've introduced an XSS vector. MV3's stricter CSP makes this harder to exploit, but the right move is to stop using it entirely:
// ✅ DOM API — safe, explicit, CSP-compliant
function createPRCard(pr) {
const card = document.createElement('div');
card.className = 'pr-card';
card.dataset.id = pr.id;
const title = document.createElement('h3');
title.textContent = pr.title; // textContent never executes HTML
const score = document.createElement('span');
score.className = 'score';
score.textContent = pr.score;
card.appendChild(title);
card.appendChild(score);
return card;
}
More verbose, but explicit and safe.
Change #5: Message passing between SW and popup requires explicit design
In MV2, background scripts and popups shared a browsing context in some ways that made communication feel more natural. In MV3, the SW and popup are completely separate contexts. Communication requires chrome.runtime.sendMessage / chrome.runtime.onMessage.
The bug I spent two days on: I assumed the Service Worker could stream progress updates to the popup while processing PRs. It can't — the SW sends a message, the popup receives it, but if the popup isn't actively listening at that exact moment, the message is lost.
The solution for PR Focus: use chrome.runtime.connect for persistent connections when you need streaming:
// In the popup
const port = chrome.runtime.connect({ name: 'prAnalysis' });
port.onMessage.addListener((msg) => {
if (msg.type === 'progress') updateProgressBar(msg.percent);
if (msg.type === 'complete') displayResults(msg.data);
});
port.postMessage({ type: 'start', repos: selectedRepos });
// In the service worker
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'prAnalysis') {
port.onMessage.addListener(async (msg) => {
if (msg.type === 'start') {
for (const repo of msg.repos) {
await analyzeRepo(repo, (progress) => {
port.postMessage({ type: 'progress', percent: progress });
});
}
port.postMessage({ type: 'complete', data: results });
}
});
}
});
What MV3 gives you that MV2 didn't
Better memory behavior. SWs that aren't doing anything don't use RAM. For extensions that are mostly idle (like a cost tracker), this matters.
Forced architectural discipline. The constraints of MV3 push you toward patterns that are actually better: explicit state management, clear separation between UI and background logic, proper async handling. The code is better for it, even if it took longer to write.
Firefox compatibility. Firefox adopted MV3 (with some differences). Building MV3-native means the Firefox port is substantially closer. That's on my roadmap.
The one thing I'd tell someone starting today
Read the Service Worker lifecycle documentation before writing a single line of background code. Not the "getting started" guide — the lifecycle specifically. Understanding that the SW can and will be terminated between events changes how you design everything.
I didn't read it carefully enough before starting. I paid for that with two days of debugging behavior that made no sense until I understood the lifecycle.
All the decisions behind this are documented in Build Logs — a public engineering journal where I write up real decisions from building these extensions, including the ones that were wrong.
The extensions themselves: PR Focus Pro (AI PR triage) and TabCost Pro (idle tab cost tracker).









