5 Manifest V3 Gotchas That Cost Me Way More Time Than They Should Have
I've lost more hours to Chrome extension development than I'd like to admit. Not because extensions are hard, exactly - it's that Manifest V3 changes a bunch of assumptions that feel completely reasonable until they quietly break your extension in production, usually a week after you shipped it and stopped thinking about it. Here are the five that got me the worst, and how I'd fix them if I were starting over.
1. Your service worker is not a background page
Coming from Manifest V2, it's tempting to treat your service worker like the old persistent background page - just a script that runs and keeps its state in memory. It isn't. Chrome kills idle service workers aggressively (sometimes in under a minute), and when a new event comes in, it spins up a fresh instance. Any global variable you were relying on is gone.
// โ This "works" in dev and then silently breaks in production
let userSettings = null;
chrome.runtime.onInstalled.addListener(() => {
userSettings = { theme: "dark" }; // gone the moment the SW sleeps
});
The fix is boring but non-negotiable: anything that needs to survive between events goes into chrome.storage, not a variable.
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.local.set({ userSettings: { theme: "dark" } });
});
2. Listeners registered "later" just... don't fire
This one is sneakier. If you register an event listener inside a promise, a callback, or after an await, it may never fire - because by the time that code runs, Chrome has already decided nothing is listening and moved on.
// โ Registered too late - event may already have fired and been missed
chrome.storage.local.get(["enabled"], ({ enabled }) => {
chrome.action.onClicked.addListener(handleClick);
});
Listeners need to be registered synchronously, at the top level of your script, every time it runs:
// โ
chrome.action.onClicked.addListener(handleClick);
async function handleClick(tab) {
const { enabled } = await chrome.storage.local.get(["enabled"]);
// ...
}
3. CSP will quietly reject your inline scripts
If you're copy-pasting popup HTML from an old tutorial or an old extension of your own, there's a decent chance it has an inline <script> tag or an onclick="..." attribute somewhere. Manifest V3's content security policy doesn't allow either - no inline scripts, no eval, no string-based setTimeout.
<!-- โ Silently fails, no script runs, no obvious error in the UI -->
<button onclick="doThing()">Click me</button>
Move everything into an external file and attach listeners in JS instead. It's more files, but it's also just... better practice anyway.
4. Forgetting return true breaks async message passing
Message passing between your popup, content script, and service worker is one of those things that works fine in your first test and then mysteriously stops responding a few days later. The usual culprit: you're responding asynchronously but didn't tell Chrome to keep the channel open.
// โ sendResponse never actually reaches the caller
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
chrome.storage.local.get(["count"], (data) => {
sendResponse(data); // too late, channel already closed
});
});
// โ
the `return true` is the whole fix
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
chrome.storage.local.get(["count"], (data) => {
sendResponse(data);
});
return true; // keeps the message channel open for async response
});
5. Broad permissions get your extension flagged (and users don't trust it either)
"host_permissions": ["<all_urls>"] is the fastest way to get extra scrutiny in Chrome Web Store review, and it's also the fastest way to make a user uninstall your extension before trying it. Most of the time you don't actually need standing access to every site - you need access when the user clicks your icon.
// โ "why does this extension want access to everything?"
"host_permissions": ["<all_urls>"]
// โ
only activates in the current tab, on demand
"permissions": ["activeTab", "storage"]
If you genuinely need broader access, optional_permissions lets you request it contextually instead of up front, which reviewers (and users) tend to appreciate a lot more.
None of these are individually hard once you know about them - the problem is that MV3's docs are scattered across a dozen pages, and most tutorials still show MV2 patterns without saying so. After hitting variations of all five of these across a handful of extensions, I got tired of re-solving the same problems every time I started a new one, so I built ManifestGo - you describe the extension you want, and it generates the full project already handling service worker lifecycle, CSP-compliant scripts, and minimal-permission scoping correctly from the start. Still actively building on it, but it's saved me from re-learning these gotchas the hard way for the third time.
If you've hit other MV3 landmines I didn't cover here, I'd genuinely like to hear about them in the comments - collecting the ones that don't show up in the official docs.
Comments
No comments yet. Start the discussion.