When I started building TokenPulse as a Manifest V3 extension, I knew MV3 was stricter than MV2. I did not know how many things would break silently, produce cryptic errors, or fail only in production. Here is every significant MV3 problem I hit and how I fixed each one. Problem 1: Inline scripts violate CSP The first error I saw after installing my extension: Refused to execute inline script because it violates the following Content Security Policy directive: 'script-src 'self'' MV3 enforces a strict Content Security Policy that blocks all inline JavaScript. This includes: <script> tags with inline content onclick, onload, and other inline event handlers javascript: URLs eval() and new Function() The fix: Move all JavaScript to external files and wire all event handlers via addEventListener after DOMContentLoaded. Before (broken): <button onclick="handleClick()">Click me</button> <script> function handleClick() { ... } </script> After (correct): <button id="my-btn">Click me</button> <script src="popup.js"></script> // popup.js document.addEventListener('DOMContentLoaded', () => { document.getElementById('my-btn') .addEventListener('click', handleClick) }) function handleClick() { ... } Every single event handler in every HTML file needs this treatment. I had 23 inline handlers across my popup and welcome pages. Every one was silent until I opened the browser console. Problem 2: Service worker dies between messages This one cost me the most debugging time. The error: Error: A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received MV3 service workers are not persistent. Chrome kills them after ~30 seconds of inactivity and restarts them on demand. Code that assumes the service worker is alive between messages will fail intermittently. The broken pattern: // service-worker.js let cachedData = null // Dies with the worker chrome.runtime.onMessage.addListener(async (msg, sender, sendResponse) => { // BROKEN: async on the listener returns a Promise, not true const data = await fetchSomething() sendResponse(data) }) Two bugs here: async listener and in-memory state. The fix: chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === 'GET_DATA') { // Correct: IIFE for async, returns true synchronously ;(async () => { const data = await Storage.getData() // Always from storage sendResponse(data) })() return true // Keep channel open } if (msg.type === 'SAVE_DATA') { // Fire and forget — no response needed Storage.saveData(msg.data) return false // Close channel immediately } }) The rule: never return true unless you are going to call sendResponse. Never use async on the listener itself. Always use chrome.storage instead of in-memory state. Problem 3: web_accessible_resources blocks script injection My content script injected a page-context script to intercept fetch calls: const script = document.createElement('script') script.src = chrome.runtime.getURL('injected.js') document.head.appendChild(script) This silently failed — the script element was added to the DOM but the script never executed. No error in the console. The fix: Declare the injected script in web_accessible_resources in manifest.json: { "web_accessible_resources": [{ "resources": ["injected.js"], "matches": ["https://claude.ai/*", "https://chatgpt.com/*"] }] } The matches array must include every domain where the script will be injected. Using <all_urls> works but is overkill and may trigger Chrome Web Store review scrutiny. Problem 4: Alarms require permissions I used chrome.alarms for periodic polling. The alarm was created but never fired. chrome.alarms.create('poll', { periodInMinutes: 5 }) // Never fired The fix: Add the alarms permission to manifest.json: { "permissions": ["alarms", "storage", "notifications"] } Missing permissions fail silently in many cases — the API exists and the call succeeds but nothing happens. Always check the permissions list when a Chrome API does not behave as expected. Problem 5: Notifications require both permission and icon My notifications never appeared. No error, just silence. chrome.notifications.create('alert', { type: 'basic', title: 'TokenPulse', message: 'You are at 90% of your limit', // Missing: iconUrl }) The fix: iconUrl is required for notifications to display: chrome.notifications.create('alert', { type: 'basic', iconUrl: chrome.runtime.getURL('icons/icon48.png'), title: 'TokenPulse', message: 'You are at 90% of your limit', priority: 1, }) Also ensure the icon file is declared in web_accessible_resources if it is being accessed from a content script or injected page context. Problem 6: Content script world isolation My content script set a global variable that I expected to read from the injected page script: // content-script.js window.tokenPulseData = { ready: true } // injected.js (page context) console.log(window.tokenPulseData) // undefined The reason: Content scripts run in an isolated world. They have their own window object separate from the page's window. Setting window.x in a content script does not affect window.x in the page. The fix: Use custom events to communicate between contexts: // content-script.js → page context window.dispatchEvent(new CustomEvent('tp-init', { detail: { ready: true } })) // injected.js (page context) window.addEventListener('tp-init', (e) => { console.log(e.detail.ready) // true }) Problem 7: chrome.storage.local is async everywhere MV2 background pages could use synchronous APIs. MV3 service workers cannot. Every chrome.storage operation is async: // Broken — storage.get is async const data = chrome.storage.local.get('key') console.log(data.key) // undefined // Correct const result = await chrome.storage.local.get('key') console.log(result.key) // correct value // Or with callback chrome.storage.local.get('key', (result) => { console.log(result.key) }) This is a pervasive change if you are migrating from MV2. Every storage read needs to be awaited or use a callback. Problem 8: Extension context invalidated after reload When you reload an extension during development, content scripts already injected into open tabs become orphaned. Their chrome.runtime context is invalidated: Error: Extension context invalidated This only happens during development but it is confusing. The fix is to reload the tab after reloading the extension, or wrap chrome API calls in try-catch during development: function safeSendMessage(msg) { try { chrome.runtime.sendMessage(msg) } catch (e) { if (e.message.includes('Extension context invalidated')) { // Context gone — page needs refresh return } throw e } } The debugging workflow that saved me For content script issues: Add debugger statements and use Chrome's "Inspect" on the content script in DevTools → Sources → Content Scripts. For service worker issues: Go to chrome://extensions → your extension → "Service Worker" link → opens a dedicated DevTools for the worker. For popup issues: Right-click the extension icon → "Inspect popup" — opens DevTools attached to the popup window. For manifest issues: chrome://extensions shows errors prominently. Always check here first when something silently fails. Full source at github.com/anu-ship-it/TokenPulse. TokenPulse — free token tracker for Claude, ChatGPT, Gemini, DeepSeek and Grok.