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).
Top comments (5)
That
startTimerexample has a quiet race in it that MV3 makes more likely, not less. Two events (sayonActivatedfiring while the alarm fires) can both readtabTimers, change their own slice, and write the whole object back, so the second write clobbers the first. In MV2 it was hidden because the object lived in memory and you mutated it in place. Now that every change is a read from storage followed by a write, the gap between the two is real. For a counter like this it's mostly harmless, but anywhere two writers touch the same key you end up wanting a small serialized queue, or one writer that owns the object. The lifecycle warning at the end is the right call too, that doc is exactly where I'd send anyone starting on MV3.Great catch, Nazar — you're absolutely right. That's a real race condition that I glossed over in the example for brevity, and MV3 makes it more likely, not less.
You're describing exactly the problem: two async events (e.g.,
onActivatedandonAlarm) can both read the sametabTimersobject from storage, modify their own slice, and write back, causing the second write to clobber the first.How I actually solved it in production:
For TabCost Pro, I use a single-writer pattern for the
tabTimersobject. Instead of multiple events writing to storage concurrently, I funnel all updates through a queue:The deep dive into IndexedDB constraints in MV3 was
Thanks for the comment, Frank — and you've spotted exactly the part that took me the longest to get right.
The IndexedDB constraints in MV3 are one of those things that look straightforward in the docs but have a bunch of non-obvious edge cases in practice.
A few things I learned the hard way:
1. Transaction lifecycle is tighter than you think.
In a content script or popup, you can hold a transaction open for a few seconds while you do async work. In a Service Worker, Chrome can terminate the SW while a transaction is still pending, and the transaction just... disappears. No error, no retry — it's gone.
The fix: keep transactions as short as possible, and never assume a transaction will survive an
awaitthat takes more than a few milliseconds.2. IndexedDB availability in Service Workers was buggy until Chrome 102.
If you're supporting older Chrome versions (some enterprise environments still use Chrome 100-101), you need to test explicitly. I had to add a fallback path that uses
chrome.storage.localfor small datasets when IndexedDB isn't available in the SW context. Not ideal, but necessary.3. Quota management is your responsibility.
Chrome doesn't clean up IndexedDB automatically. If you store summaries for 500+ PRs, you'll eventually hit the quota limit. I added a simple LRU cache eviction policy: keep the 200 most recent PRs, delete the rest. That was enough to keep it under the limit without losing useful data.
4. Schema migrations are painful.
Unlike
chrome.storage, IndexedDB requires explicit version upgrades. I have a migration path inopenDB()that handles adding new indexes without breaking existing data. It's not complicated, but you have to remember to test it before releasing.If you're working on something that uses IndexedDB in a Service Worker, I'd be curious to hear what challenges you've run into — there's always something I haven't seen yet.
I documented the full decision (including the "why IndexedDB over
chrome.storage.local" trade-off) in my Build Logs: [link to build-logs repo]Always happy to swap notes on this stuff. 👍
Some comments may only be visible to logged-in visitors. Sign in to view all comments.