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:
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.
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.
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.jsasync 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 workingawait 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 tabawait chrome.scripting.executeScript({target: { tabId: tab.id },files: ["content.js"]}).catch(err => console.error("Hard injection failure:", err.message));}}
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.jschrome.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;});
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 clashesif (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 });}});