Chrome extension error
Could not establish connection. Receiving end does not exist.
5 causes, how to tell them apart, and the Manifest V3 fix for each.
- Service worker
- Content script
- Popup or side panel
- Web page
What it meansChrome sent your message, and no listener was registered at the other end at that moment. The sender is fine; the receiver is missing, asleep without a listener, or a different extension than you think.
Find your cause
- The tab has no content script listening.
How to confirm: Open DevTools on that tab and look under Sources > Content scripts. If your extension is not listed, nothing is listening. If refreshing the tab makes the error go away, the tab loaded before your extension was installed or reloaded.
- The service worker messages a popup or side panel that is closed.
How to confirm: The error shows up in the service worker console only while the popup is closed. Open the popup and the same call succeeds.
chrome.runtime.sendMessagereaches extension pages, never the context that sent it. - The service worker registers its listener after an
await.How to confirm: The first message after the worker has been idle fails, and messages sent while it is awake succeed. The bug hides while you debug, because an open service worker DevTools window keeps the worker alive. Stop the worker from chrome://serviceworker-internals, then send again.
- A web page messages an extension ID that is not installed.
How to confirm: Compare the ID your page sends to with
chrome.runtime.idin the extension's service worker console. An unpacked build gets a different ID from the Chrome Web Store build unless you pin it. - The extension listens with
onMessage, but the sender is a web page.How to confirm: The page origin is in
externally_connectableand the ID is right, yet the call fails. Search the service worker foronMessageExternal: messages from web pages never reachruntime.onMessage.
Fixes
The tab has no content script listening.
Manifest content scripts are injected when a page loads, so tabs that were already open, pages outside your matches patterns, and chrome:// pages have no listener. Catch the error, inject the script on demand, and retry once. Injection needs the scripting permission and host access to the tab, and it still fails on restricted pages.
async function sendToTab<T>(tabId: number, message: unknown): Promise<T> {
try {
return await chrome.tabs.sendMessage(tabId, message);
} catch (error) {
if (!String(error).includes('Receiving end does not exist')) throw error;
// No listener in this tab yet: inject the content script, then retry once.
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] });
return chrome.tabs.sendMessage(tabId, message);
}
}
The service worker messages a popup or side panel that is closed.
A closed popup is a normal state, not a failure. Check whether a view is open with chrome.runtime.getContexts, and still catch the rejection, because the popup can close between the check and the send. For state the popup needs when it opens, write to chrome.storage and read it on open.
async function notifyOpenViews(message: unknown): Promise<void> {
const views = await chrome.runtime.getContexts({ contextTypes: ['POPUP', 'SIDE_PANEL'] });
if (views.length === 0) return;
// The popup can close after the check; a missed update is fine.
await chrome.runtime.sendMessage(message).catch(() => {});
}
The service worker registers its listener after an await.
When a message wakes the worker, Chrome dispatches it as soon as the script has run once. A listener added after loading settings is not there yet. Register every listener synchronously at the top level, and wait for initialization inside the handler instead.
let ready: Promise<void> | null = null;
function initialize(): Promise<void> {
ready ??= loadSettingsAndRules().catch((error) => {
ready = null; // let the next event retry
throw error;
});
return ready;
}
// Registered synchronously, so it exists when a message wakes the worker.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
void (async () => {
try {
await initialize();
sendResponse(await handleMessage(message, sender));
} catch (error) {
sendResponse({ success: false, error: String(error) });
}
})();
return true; // keep the channel open for the async reply
});
A web page messages an extension ID that is not installed.
Treat this error as "extension not available" and show a fallback, with a timeout in case no callback ever arrives. For local development, pin the unpacked ID with the manifest key field, or read the ID from per-environment config.
type ExtensionRuntime = {
lastError?: { message?: string };
sendMessage: (id: string, message: unknown, callback: (response: unknown) => void) => void;
};
function askExtension(extensionId: string, message: unknown, timeoutMs = 2500): Promise<unknown> {
return new Promise((resolve, reject) => {
const runtime = (window as Window & { chrome?: { runtime?: ExtensionRuntime } }).chrome?.runtime;
// Undefined unless an installed extension lists this origin in externally_connectable.
if (!runtime?.sendMessage) return reject(new Error('unavailable'));
const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
runtime.sendMessage(extensionId, message, (response) => {
clearTimeout(timer);
// Wrong ID, extension not installed, or no onMessageExternal listener.
if (runtime.lastError) reject(new Error('unavailable'));
else resolve(response);
});
});
}
The extension listens with onMessage, but the sender is a web page.
Add a runtime.onMessageExternal listener at the top level and check the sender's origin before doing anything, because any page on a listed origin can send to it.
const TRUSTED_ORIGINS = new Set(['https://www.example.com']);
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
if (!sender.origin || !TRUSTED_ORIGINS.has(sender.origin)) {
sendResponse({ success: false, error: 'Unauthorized sender' });
return false;
}
void handleExternal(message).then(sendResponse);
return true;
});
What does not cause it
- The service worker was asleep, so the message was lost.Not on its own. After stopping the worker, a message to a top-level
onMessagelistener woke it and got a reply. The same test failed only when the listener was registered after anawait. Forum answers that blame sleep are often describing that late listener, or Firefox event pages. - Adding
return trueto the listener fixes it.return truekeeps a channel open for a reply that comes later. It does nothing when there is no listener to return it. A listener that never replies produces a different error: "The message port closed before a response was received."
How ResistGate hit this
ResistGate's install page lives on this website and asks the extension for its setup over chrome.runtime.sendMessage(extensionId, …). Only the Chrome Web Store build answers. A local unpacked build has a different ID, so the page gets this error and shows its "can't reach ResistGate" fallback instead of hanging.
The worker side went through the late-listener bug first. In February 2026, commit 0bec544 moved every listener to the top level of background.js and made each handler await a shared initializeExtension(reason) promise, which is the pattern in the third fix above.