Chrome Extension Tab Communication — Best Practices

6 min read

Tab Communication Patterns

Overview

Chrome extensions operate across multiple execution contexts: the service worker, popup, options page, and content scripts running in each tab. These contexts cannot directly access each other’s variables or DOM, so communication must happen through message passing. This guide covers patterns for communicating between the service worker and content scripts, as well as direct tab-to-tab communication through relay mechanisms.

Understanding these patterns is essential for building extensions that coordinate state across tabs, push updates to specific pages, or aggregate data from multiple sources.

Service Worker to Content Script

Use chrome.tabs.sendMessage() to send targeted messages to a specific tab. The content script must have an active listener for messages to receive them.

// In service worker - send to specific tab
async function updateTabState(tabId, newState) {
  try {
    await chrome.tabs.sendMessage(tabId, {
      type: 'STATE_UPDATE',
      payload: newState
    });
  } catch (error) {
    // Error: No content script listening in this tab
    console.error('Failed to send message:', error.message);
  }
}

// In content script - listen for messages
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'STATE_UPDATE') {
    applyStateUpdate(message.payload);
  }
  return true; // Keep channel open for async response
});

The tabId can be obtained from chrome.tabs.query() or from the sender.tab property in message listeners. Note that sendMessage will throw if no content script is actively listening in the target tab.

Content Script to Service Worker

Content scripts can send messages to the service worker using chrome.runtime.sendMessage(). Unlike tab-specific messaging, this goes to the extension’s background context.

// In content script - send to service worker
async function reportToServiceWorker(data) {
  const response = await chrome.runtime.sendMessage({
    type: 'TAB_DATA',
    tabId: chrome.runtime.id,
    payload: data
  });
  return response;
}

// In service worker - receive from content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'TAB_DATA') {
    // Process data from content script
    handleTabData(message.payload, sender.tab?.id);
    sendResponse({ success: true });
  }
  return true;
});

The sender.tab property contains information about which tab the message originated from, allowing the service worker to track which content script sent the data.

Broadcast to All Tabs

When you need to update all open extension pages simultaneously, query all tabs and send messages to each one. Handle errors gracefully since some tabs may not have content scripts injected.

// Broadcast message to all tabs with content scripts
async function broadcastMessage(message) {
  const tabs = await chrome.tabs.query({});
  
  const results = await Promise.allSettled(
    tabs.map(tab => 
      chrome.tabs.sendMessage(tab.id, message).catch(err => ({
        tabId: tab.id,
        error: err.message
      }))
    )
  );
  
  return results;
}

// Usage
await broadcastMessage({
  type: 'GLOBAL_STATE_CHANGE',
  payload: { theme: 'dark' }
});

Use this pattern sparingly—frequent broadcasts to many tabs can impact performance. Consider using chrome.storage with change listeners instead for high-frequency updates.

Long-Lived Connections

For streaming data or persistent communication channels, use the Port API. Ports remain open until explicitly disconnected and survive service worker restarts.

// Service worker - create port to specific tab
function connectToTab(tabId) {
  const port = chrome.tabs.connect(tabId, { name: 'stream-channel' });
  
  port.onMessage.addListener((msg) => {
    console.log('Received:', msg);
  });
  
  port.onDisconnect.addListener(() => {
    console.log('Port disconnected');
  });
  
  // Send messages through the port
  port.postMessage({ type: 'INIT' });
  
  return port;
}

// Content script - connect back to service worker
const port = chrome.runtime.connect({ name: 'stream-channel' });

port.onMessage.addListener((msg) => {
  handleStreamMessage(msg);
});

port.postMessage({ type: 'READY' });

Ports automatically disconnect when the user navigates away or closes the tab, making them ideal for real-time data streaming.

Tab-to-Tab Communication via Service Worker Relay

Direct tab-to-tab communication is not possible. Instead, route messages through the service worker using a relay pattern.

// Tab A sends to Tab B via service worker
// Step 1: Tab A -> Service Worker
chrome.runtime.sendMessage({
  type: 'RELAY_TO_TAB',
  targetTabId: tabBId,
  payload: { data: 'hello' }
});

// Step 2: Service Worker receives and forwards to Tab B
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'RELAY_TO_TAB') {
    chrome.tabs.sendMessage(message.targetTabId, {
      type: 'RELAYED_MESSAGE',
      from: sender.tab?.id,
      payload: message.payload
    });
  }
  return true;
});

This pattern scales to N tabs but introduces latency. For simpler use cases, consider using chrome.storage.onChanged as a lightweight broadcast mechanism—all tabs listening to storage changes receive notifications when any tab updates the storage.

Typed Messaging with TypeScript

For type-safe messaging, use a library like @theluckystrike/webext-messaging or define TypeScript interfaces:

// types/messages.ts
interface StateUpdateMessage {
  type: 'STATE_UPDATE';
  payload: { theme: string };
}

interface RelayMessage {
  type: 'RELAY_TO_TAB';
  targetTabId: number;
  payload: unknown;
}

type ExtensionMessage = StateUpdateMessage | RelayMessage;

// Usage with type guards
function isStateUpdate(msg: ExtensionMessage): msg is StateUpdateMessage {
  return msg.type === 'STATE_UPDATE';
}

Cross-References

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.