DEV Community

Cover image for MV3 Chrome Extensions — Everything That Broke and How I Fixed It
Anoop Kumar
Anoop Kumar

Posted on

MV3 Chrome Extensions — Everything That Broke and How I Fixed It

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''
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

After (correct):

<button id="my-btn">Click me</button>
<script src="popup.js"></script>
Enter fullscreen mode Exit fullscreen mode
// popup.js
document.addEventListener('DOMContentLoaded', () => {
  document.getElementById('my-btn')
    .addEventListener('click', handleClick)
})

function handleClick() { ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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
  }
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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/*"]
  }]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The fix: Add the alarms permission to manifest.json:

{
  "permissions": ["alarms", "storage", "notifications"]
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode
// injected.js (page context)
console.log(window.tokenPulseData) // undefined
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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)
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)