Broadcasting Messages to All Tabs

Send one message to every content script in MV3 without unhandled promise rejections: enumerating tabs, tolerating frames with no listener, batching, and using storage.onChanged as a broadcast instead.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

The user flips a setting and every open tab should react. chrome.runtime.sendMessage will not do it — that call goes to the extension, not to content scripts — so the natural next step is a loop over chrome.tabs.query, and that loop produces a console full of “Could not establish connection. Receiving end does not exist.” This page is part of Message Passing Architecture and covers a broadcast that stays quiet, plus the storage-based alternative that often replaces it entirely.

Root cause: most tabs have no listener

chrome.tabs.sendMessage targets one tab, and it rejects when that tab has no listener for your extension. That is the common case, not the exception: the browser’s own pages, the web store, PDF viewers, tabs that loaded before your extension was installed, and every tab whose URL your matches patterns do not cover all lack a content script. A broadcast loop therefore has to treat “no receiver” as an ordinary outcome rather than an error.

Which tabs in a normal window can actually receive a messageOf the tabs open in a typical window, only those matching the content script patterns have a listener; the rest reject the send.tabs.query({})every tab, every windowMatching originscontent script injectedEverything elseno listener at allthe second group answers; the third rejectsBrowser pageschrome://, about:Store pagesinjection blockedPre-install tabsnever injectedDiscarded tabsno renderer
The rejections are not failures — they are the shape of a real browser window.

Step 1. Enumerate only the tabs worth trying

Filtering the query is cheaper than filtering the failures. Query by URL pattern so the browser does the work, and drop tabs with no id or a discarded renderer before sending anything.

1// background.js
2const CONTENT_MATCHES = ["https://*/*", "http://*/*"];
3
4async function receivableTabs() {
5  // The url filter needs host access; without it, query returns tabs with no url field.
6  const tabs = await chrome.tabs.query({ url: CONTENT_MATCHES });
7  return tabs.filter((tab) => typeof tab.id === "number" && !tab.discarded);
8}

Execution context: Extension service worker. chrome.tabs.query with a url filter requires the tabs permission or matching host permissions — without either, tab.url is omitted and the filter silently matches nothing, which looks identical to “no tabs open”. discarded tabs have been unloaded to save memory and have no renderer to receive anything; they will re-run your content script when the user returns to them. See querying the active tab safely for the permission details.

Step 2. Swallow the expected rejection, keep the unexpected one

Wrapping the whole loop in one try hides real errors. Catch per tab, and inspect the message so a genuine bug in your content script still surfaces.

 1// background.js
 2const NO_RECEIVER = /Receiving end does not exist|Could not establish connection/i;
 3
 4async function broadcast(message) {
 5  const tabs = await receivableTabs();
 6
 7  const results = await Promise.allSettled(
 8    tabs.map(async (tab) => {
 9      try {
10        return { tabId: tab.id, reply: await chrome.tabs.sendMessage(tab.id, message) };
11      } catch (error) {
12        // Expected: this tab simply has no listener. Anything else is worth knowing about.
13        if (NO_RECEIVER.test(String(error?.message))) return { tabId: tab.id, skipped: true };
14        throw error;
15      }
16    }),
17  );
18
19  const failed = results.filter((r) => r.status === "rejected");
20  if (failed.length) console.warn("broadcast: unexpected failures", failed.map((f) => f.reason));
21
22  return results
23    .filter((r) => r.status === "fulfilled" && !r.value.skipped)
24    .map((r) => r.value);
25}

Execution context: Extension service worker. Promise.allSettled is the important choice: Promise.all would abandon the remaining sends the moment one rejected, so a single browser tab early in the list could stop the broadcast reaching everything after it. Matching on the error message is unavoidable — there is no error code — so keep the pattern permissive and re-throw anything else, which is how a syntax error in your content script stays visible instead of being counted as “no listener”.

One broadcast across three kinds of tabThe worker sends to each candidate tab; a matching tab replies, a browser page rejects with no receiver, and a discarded tab is skipped before the send.Service workerMatching tabBrowser pageDiscarded tabtabs.sendMessage(id, msg)applied — replytabs.sendMessage(id, msg)rejects: no receiving endexpected, swallow itfiltered out before sendingtab.discarded
Two of the three outcomes are non-errors, which is why the per-tab catch has to distinguish them.

Step 3. Reach every frame, not just the top one

A message sent to a tab id is delivered to every frame in that tab that has a listener, and each frame answers separately — but chrome.tabs.sendMessage returns only the first response. When frames matter, address them explicitly.

 1// background.js — one send per frame, so each reply is attributable
 2async function broadcastToFrames(tabId, message) {
 3  const frames = await chrome.webNavigation.getAllFrames({ tabId });
 4  if (!frames) return [];
 5
 6  return Promise.allSettled(
 7    frames.map((frame) =>
 8      chrome.tabs.sendMessage(tabId, message, { frameId: frame.frameId }),
 9    ),
10  );
11}

Execution context: Extension service worker; chrome.webNavigation.getAllFrames needs the webNavigation permission. Sending per frame is the only way to know which frame answered what, and it is also the only way to reach a frame whose parent has no listener. Frames the extension cannot access reject exactly like an unmatched tab, so the same per-frame tolerance applies — see injecting content scripts into dynamic iframes for which frames are reachable at all.

Step 4. Consider not broadcasting at all

For state changes — which is what most broadcasts carry — chrome.storage.onChanged is a better fit. Every context that cares registers a listener, the write is a single call regardless of tab count, and there are no rejections to filter because the browser delivers only to listeners that exist.

Broadcast loop versus a storage writeComparison of tabs.sendMessage in a loop against storage.onChanged for delivering a state change to every context.PropertyBroadcast loopstorage.onChangedCalls for 40 tabs40 sendsOne writeRejections to handleOne per listener-less tabNoneReaches extension pagesNoYesNew tabs get the valueNo, missed itYes, on readCarries a one-off commandYesAwkwardPer-tab repliesYesNo
The loop wins only when the message is a command that must be acted on now and must not be replayed later.

The last two rows are the deciding ones. “Highlight the term the user just typed” is a command with per-tab results and belongs in a broadcast; “the user turned highlighting off” is state, and a content script that reads it on injection will get the right answer without ever being told. In practice most extensions need both, with the broadcast reserved for the narrow case — and the storage listener patterns doing the rest.

Step 5. Keep the cost bounded as tab counts grow

A broadcast is one wake plus one send per tab, and users with a hundred tabs are not rare. Two limits are worth imposing: a concurrency cap so a hundred simultaneous sends do not queue behind each other, and a coalescing window so a rapid series of setting changes produces one broadcast rather than five.

 1// background.js
 2let pending = null;
 3
 4export function requestBroadcast(message) {
 5  // Collapse a burst of changes into a single broadcast.
 6  pending = message;
 7  clearTimeout(requestBroadcast.timer);
 8  requestBroadcast.timer = setTimeout(() => {
 9    const payload = pending;
10    pending = null;
11    void broadcastLimited(payload, 12);
12  }, 120);
13}
14
15async function broadcastLimited(message, concurrency) {
16  const tabs = await receivableTabs();
17  const queue = [...tabs];
18
19  const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
20    while (queue.length) {
21      const tab = queue.shift();
22      try {
23        await chrome.tabs.sendMessage(tab.id, message);
24      } catch (error) {
25        if (!NO_RECEIVER.test(String(error?.message))) console.warn("broadcast", tab.id, error);
26      }
27    }
28  });
29
30  await Promise.all(workers);
31}

Execution context: Extension service worker. The concurrency pool keeps the number of in-flight sends bounded without dropping any tab, which matters because each send that has to wake a discarded renderer is comparatively slow. The 120 ms coalescing window is short enough to feel immediate to the user and long enough to swallow the several onChanged events a settings form produces while a control is being dragged.

Cross-browser variation

  • Chrome 120+: tabs.sendMessage returns a promise in MV3 and rejects with the “Receiving end does not exist” text. tab.discarded is available and worth filtering on.
  • Firefox 121+: browser.tabs.sendMessage rejects with a different message — “Could not establish connection. Receiving end does not exist.” — hence the permissive regex covering both wordings.
  • Safari 17+: rejects for listener-less tabs with its own wording again, and does not populate tab.discarded; treat a missing field as “not discarded” and let the send fail normally.
  • All engines: the query url filter needs host access. Without it you get tabs with no url, so build the fallback on tabs.query({}) plus a manual origin check rather than assuming an empty result means no tabs.

Verification

  1. Open ten tabs including a browser settings page and a store page, then broadcast. The console should be clean, and the returned array should contain only the matching tabs.
  2. Introduce a deliberate throw in the content script listener and broadcast again. That tab must appear in the unexpected-failures warning, proving the error classification works.
  3. Discard a tab from the browser task manager and broadcast. It should be filtered out before the send, not rejected.
  4. Compare the broadcast against a single chrome.storage.local.set for the same state change, and count the API calls in each.

FAQ

Why does runtime.sendMessage not reach content scripts?

It is addressed to the extension: the popup, options page, offscreen document and service worker. Content scripts are reached with chrome.tabs.sendMessage and a tab id, which is what makes a broadcast a loop rather than a single call.

Can I broadcast without the tabs permission?

Yes, if you have host permissions covering the origins you want to reach — those grant tab URL access for matching tabs. What you cannot do is enumerate tabs you have no access to, which is the intended behaviour.

Should the content script reply to a broadcast?

Only if the worker uses the reply. A listener that returns nothing lets the channel close immediately, which is cheaper than holding it open with return true for a response nobody reads.

How do I make sure a newly opened tab gets the current state?

Have the content script read the state on injection rather than waiting to be told. That single change removes the class of bug where a tab opened one second after the broadcast is permanently out of step.

Other Core APIs & Cross-Browser Data Management Resources