Skip to content

Fix "Could not establish connection. Receiving end does not exist."

7 min readModerok team

Why a Chrome extension throws "Could not establish connection. Receiving end does not exist.", the inject-and-retry fix, and what still breaks after it.

Could not establish connection. Receiving end does not exist. means your message was delivered to nobody: at the moment you called sendMessage, no chrome.runtime.onMessage listener existed in the context you aimed at. In the common case, a service worker messaging a tab, the fix is to catch the failure, inject the content script, and send again:

// background.js (service worker)
async function sendToTab(tabId, message) {
  try {
    return await chrome.tabs.sendMessage(tabId, message);
  } catch {
    await chrome.scripting.executeScript({ target: { tabId }, files: ["content.js"] });
    return await chrome.tabs.sendMessage(tabId, message);
  }
}

That needs "scripting" plus host access to the tab in your manifest:

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "background": { "service_worker": "background.js" },
  "permissions": ["scripting"],
  "host_permissions": ["https://example.com/*"],
  "content_scripts": [
    { "matches": ["https://example.com/*"], "js": ["content.js"] }
  ]
}

Chrome's scripting reference states the requirement: "To use the browser.scripting API, declare the "scripting" permission in the manifest plus the host permissions for the pages to inject scripts into. Use the "host_permissions" key or the "activeTab" permission, which grants temporary host permissions."

Take the host_permissions half and match the patterns to your content_scripts entry. activeTab will not carry this pattern, because it only becomes active on a user action: Chrome lists the triggers as "Executing an action," "Executing a context menu item," "Executing a keyboard shortcut from the commands API," and "Accepting a suggestion from the omnibox API." A service worker repairing a stranded tab on its own has had none of those, so executeScript fails there. Reach for activeTab only when the injection really is driven by a toolbar click.

Write content.js so it is safe to run twice, because the fallback can inject it into a tab that already has it. A manifest-declared content script is a classic script, so top-level return is a syntax error; wrap the body in an IIFE and set the flag you check:

// content.js
(() => {
  if (window.__myExtLoaded) return;
  window.__myExtLoaded = true;

  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    sendResponse({ ok: true });
  });
})();

Why "Receiving end does not exist" appears after an install or a reload

The most common trigger is not a bug in your message code at all. It is that the tab you are messaging was opened before your content script existed. Chrome's manifest reference describes the content_scripts key as one that "specifies a statically loaded JavaScript or CSS file to be used every time a page is opened that matches a certain URL pattern." The trigger is the page being opened. A tab that finished loading before you installed the extension, or before you hit the reload icon in chrome://extensions, never had that injection run, so no amount of retrying chrome.tabs.sendMessage will find a listener in it.

This is why the error is constant in development and rare in a clean profile: every extension reload strands every open tab. Any script injected before the reload is still running in the page, but it belongs to an extension instance that is gone, which is the situation behind what "Extension context invalidated" means.

Reloading the page fixes it for you. Shipping the executeScript fallback fixes it for users, who will not think to reload.

The four other causes, in the order worth checking

1. The tab's URL does not match. Chrome's content_scripts reference gives the rule in two parts: a content script is injected into a page if "Its URL matches any "matches" and "include_globs" patterns" and the URL "doesn't match "exclude_matches" or "exclude_globs" patterns." The scheme component of a match pattern "must be one of the following": http, https, a wildcard * that "matches only http or https", or file. So chrome:// and chrome-extension:// tabs can never match a content_scripts entry, and a settings tab will produce this error every time.

Checking the URL first has its own catch. The "tabs" permission is what "grants an extension the ability to call tabs.query() against four sensitive properties on tabs.Tab instances: url, pendingUrl, title, and favIconUrl," and host permissions expose those properties only for tabs they match. So with the manifest above, tab.url is empty for precisely the tabs you wanted to detect. That absence is the signal: no readable URL means no access, so skip the tab instead of messaging it.

2. You used runtime.sendMessage to reach a content script. The docs say so directly: "extensions cannot send messages to content scripts using this method. To send messages to content scripts, use tabs.sendMessage," which "sends a single message to the content script(s) in the specified tab."

3. The sender is the only listener. chrome.runtime.sendMessage fires onMessage "in every frame of your extension (except for the sender's frame)." A popup that sends a message and also registers the handler for it will never receive its own message. The listener has to live in another context, usually the service worker.

4. The service worker registered no listener. A worker that threw during its first evaluation, or that registers onMessage inside a .then() or after an await, has nothing listening when a message arrives. Chrome's guidance is explicit: event handlers "should be at the top level of the script and not be nested inside functions," which "ensures that they are registered synchronously on initial script execution, which enables Chrome to dispatch events to the service worker as soon as it starts." Open its console from chrome://extensions and confirm it evaluated cleanly.

What still breaks after the fix

The inject-and-retry pattern removes the "no receiver" case. Three things it does not cover:

A listener that exists but answers too late. Once a listener is found, this error is replaced by a different one, The message port closed before a response was received. That is the opposite problem: somebody was listening, but sendResponse was called after the listener already returned. Chrome's messaging docs give the rule: "To respond asynchronously using sendResponse(), return a literal true (not just a truthy value) from the event listener." The full set of ways that goes wrong is in why the message port closes before a response.

Console noise you cannot suppress by ignoring it. In callback style, the failure is reported through chrome.runtime.lastError, which is "populated with an error message if calling an API function fails; otherwise undefined" and "is only defined within the scope of that function's callback." MDN adds the part people miss: "If lastError has been set and you don't check it within the callback function, then an error will be raised." Reading the property inside the callback is what silences the log line, so an empty if block is a legitimate fix:

chrome.tabs.sendMessage(tabId, { type: "ping" }, (response) => {
  if (chrome.runtime.lastError) {
    // Read it so Chrome stops logging it, then decide what to do.
    return;
  }
  handle(response);
});

More than one listener answering. Chrome documents the resolution order: "If multiple listeners are registered for onMessage, only the first listener to respond, reject, or throw an error will affect the sender; all other listeners will run, but their results will be ignored." MDN is blunter: "Avoid creating multiple onMessage() listeners for the same type of message because the order in which multiple listeners fire is not guaranteed." One listener with a switch on message.type avoids the whole class of problem.

Firefox differences

Firefox produces the same error text, but it arrives differently, because browser.* APIs are promise-based. MDN describes tabs.sendMessage's return value: "If an error occurs while connecting to the specified tab or any other error occurs, the promise will be rejected with an error message." Firefox still exposes runtime.lastError for callback-style calls, but with the promise form you do not read it, and MDN says so: "You don't need to check this property if you are using the promise-based version of the APIs: instead, pass an error handler to the promise." A missing .catch() therefore surfaces as an unhandled rejection rather than an "Unchecked runtime.lastError" console line, which is easy to miss if you only tested in Chrome.

The response path also differs. Firefox lets an onMessage listener "return a Promise from the event listener, and resolve when you have the response," while MDN notes that "Promise as a return value is not supported in Chrome until Chrome bug 1185241 is resolved" and points to returning true instead. Chrome's own docs now say that "From Chrome 148, you can return a promise from a message listener to respond asynchronously," so the two are converging, but return true plus sendResponse is still the form that works in both today.

Turning it into a signal instead of a console line

This error is worth counting rather than swallowing, because it tells you how often your content script is missing when you expected it to be there. That number is invisible if it only ever lands in a console nobody reads in production.

If you already run Moderok in your extension, Moderok.captureLastError("tabs.sendMessage", chrome.runtime.lastError, { action: "inject_fallback" }) records the failure as an error event with the API name attached, so a spike after a release shows up next to your other events instead of in a bug report. The error tracking guide covers automatic capture and when to call it by hand.