Chrome Extension - Fix the "Active but Blind" Bug

That is the ultimate curveball—and a deeply frustrating quirk of Manifest V3 development. If chrome://extensions says the service worker status is "Active" and running, but you are still getting a "Cannot access contents of the page" error that magically disappears when you restart Chrome, you are dealing with a Context Invalidation Ghost.

The Service Worker thread isn't dead or sleeping; it’s literally locked out of the tab.

Here is exactly what is happening under the hood when a service worker is completely "Active" but totally blind to the webpage:

The Culprit: Token Invalidation (The Orphaned Bridge)

When your extension starts up, Chrome assigns a unique dynamic runtime token to connect your active service worker to the open webpage tab.

If the extension encounters an unhandled error inside a callback or a messaging port—or if Chrome 148's background engine flags a momentary hiccup—Chrome silently revokes that specific tab token for security.

  1. The Paradox: The service worker stays "Active" because its background process is still alive and listening.
  2. The Breakage: However, because its security token for that webpage was revoked, any attempt to inject code, send a message to the content script, or query the DOM fails instantly with "Cannot access contents of the page."
  3. Why restarting Chrome fixes it: Restarting Chrome completely kills the browser session and generates brand new security tokens for every open tab and extension, restoring the broken connection.

How to Fix the "Active but Blind" Bug

Since the service worker is awake, you cannot fix this by trying to wake it up. Instead, you have to write code that detects when the tab bridge has been silently broken and forces Chrome to recreate it.

1. The "Ping-Pong" Detection Strategy

Do not just broadcast messages to the tab and assume they arrive. When your popup opens, send a test "ping" to the webpage's content script. If Chrome blocks it, you catch the error and programmatically force a fresh script injection to bypass the stale token.

JavaScript


// Inside popup.js
async function connectToPage() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) return;

try {
// 1. Ping the content script to verify the bridge is ACTUALLY working
await chrome.tabs.sendMessage(tab.id, { action: "PING" });
console.log("Bridge is valid and healthy!");
} catch (error) {
// 2. If it fails (even though worker is active), Chrome broke the token.
console.warn("Token invalidated by Chrome. Forcing dynamic re-injection...");
// This forces Chrome to issue a fresh execution token for this tab
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ["content.js"]
}).catch(err => console.error("Hard injection failure:", err.message));
}
}

2. Check for sendResponse Ghosting in your Background Script

A major reason active service workers get their tab tokens silently revoked by Chrome is due to a messaging mismatch. If your background.js listens for a message from the popup or webpage, but forgets to close the loop properly, Chrome flags the port as compromised.

Ensure every single listener that performs an asynchronous action returns true:

JavaScript


// Inside background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === "FETCH_DATA") {
// If you are doing something async (like a fetch request):
performAsyncAction().then(data => {
sendResponse({ status: "success", data });
});
return true; // <-- CRUCIAL! This tells Chrome to keep the token alive.
}
// If you are doing something synchronous:
sendResponse({ status: "done" });
return false;
});

3. Clear Stale Channel Locks on the Tab Side

If the webpage is a Single Page Application (like YouTube, a dashboard, or a complex web app), the old content script might be holding onto a dead listener interface.

Add an unloader inside your content.js so it gracefully steps aside if Chrome glitches out the runtime token:

JavaScript


// Inside content.js
// If a re-injection happens, clean up the old listener to avoid clashes
if (window.myExtensionLoaded) {
console.log("Cleaning up old content script context...");
}
window.myExtensionLoaded = true;

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === "PING") {
sendResponse({ alive: true });
}
});