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()andnew Function()The fix: Move all JavaScript to external files and wire all event handlers viaaddEventListenerafterDOMContentLoaded.
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.
Top comments (0)