Skip to main content
Technology & EngineeringBrowser Extension275 lines

Messaging

Message passing between extension components including content scripts, service workers, popups, and external pages

Quick Summary10 lines
You are an expert in message passing between extension components for building browser extensions.

## Key Points

- Always return `true` from `onMessage` listeners when calling `sendResponse` asynchronously; otherwise the message channel closes before the response is sent.
- Define a clear message schema with `type` fields to route messages and avoid ambiguity when multiple components communicate.
- Validate `sender.origin` or `sender.url` in `onMessageExternal` handlers to prevent unauthorized web pages from interacting with your extension.
- Forgetting to return `true` in the `onMessage` listener for async responses — the port closes immediately and `sendResponse` silently fails.
skilldb get browser-extension-skills/MessagingFull skill: 275 lines
Paste into your CLAUDE.md or agent config

Messaging — Browser Extension Development

You are an expert in message passing between extension components for building browser extensions.

Overview

Browser extensions are composed of multiple isolated contexts — content scripts, service workers, popups, options pages, and offscreen documents. These contexts communicate through Chrome's messaging APIs: one-time messages for request/response patterns and long-lived connections (ports) for ongoing communication channels.

Core Concepts

One-Time Messages

Simple request/response communication between any two extension contexts:

// SENDER — from popup, options page, or content script
const response = await chrome.runtime.sendMessage({
  type: 'FETCH_DATA',
  payload: { url: 'https://api.example.com/items' }
});
console.log('Received:', response);

// RECEIVER — in background service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'FETCH_DATA') {
    fetch(message.payload.url)
      .then((res) => res.json())
      .then((data) => sendResponse({ success: true, data }))
      .catch((err) => sendResponse({ success: false, error: err.message }));

    // Return true to indicate sendResponse will be called asynchronously
    return true;
  }
});

Messaging To and From Content Scripts

Communication between the background/popup and a specific tab's content script uses chrome.tabs.sendMessage:

// SEND to content script — from background or popup
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id, {
  type: 'EXTRACT_DATA',
  selector: '.article-body'
});

// RECEIVE in content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'EXTRACT_DATA') {
    const el = document.querySelector(message.selector);
    sendResponse({
      text: el?.textContent || null,
      html: el?.innerHTML || null
    });
  }
});

Long-Lived Connections (Ports)

For ongoing bidirectional communication:

// CONTENT SCRIPT — open a port
const port = chrome.runtime.connect({ name: 'content-stream' });

port.postMessage({ type: 'INIT', url: window.location.href });

port.onMessage.addListener((msg) => {
  if (msg.type === 'HIGHLIGHT') {
    highlightElements(msg.selector);
  }
});

port.onDisconnect.addListener(() => {
  console.log('Port disconnected');
});

// BACKGROUND — accept connections
chrome.runtime.onConnect.addListener((port) => {
  if (port.name === 'content-stream') {
    console.log('Content script connected from tab:', port.sender.tab.id);

    port.onMessage.addListener((msg) => {
      if (msg.type === 'INIT') {
        port.postMessage({ type: 'HIGHLIGHT', selector: '.important' });
      }
    });
  }
});

External Messaging

Allow web pages or other extensions to send messages to your extension:

{
  "externally_connectable": {
    "matches": ["https://www.example.com/*"],
    "ids": ["other_extension_id"]
  }
}
// FROM A WEB PAGE
chrome.runtime.sendMessage('YOUR_EXTENSION_ID', {
  type: 'AUTH_TOKEN',
  token: 'abc123'
}, (response) => {
  console.log('Extension replied:', response);
});

// IN THE EXTENSION background.js
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  // Validate the sender origin
  if (sender.origin !== 'https://www.example.com') {
    sendResponse({ error: 'Unauthorized' });
    return;
  }

  if (message.type === 'AUTH_TOKEN') {
    handleAuthToken(message.token);
    sendResponse({ success: true });
  }
});

Implementation Patterns

Typed Message Router

Organize message handling with a router pattern:

// message-router.js
class MessageRouter {
  constructor() {
    this.handlers = new Map();

    chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
      const handler = this.handlers.get(message.type);
      if (!handler) {
        console.warn(`No handler for message type: ${message.type}`);
        return false;
      }

      const result = handler(message.payload, sender);
      if (result instanceof Promise) {
        result.then(sendResponse).catch((err) =>
          sendResponse({ error: err.message })
        );
        return true; // async response
      }

      sendResponse(result);
      return false;
    });
  }

  on(type, handler) {
    this.handlers.set(type, handler);
    return this;
  }
}

// Usage in background.js
const router = new MessageRouter();

router
  .on('GET_SETTINGS', async () => {
    return await chrome.storage.sync.get('settings');
  })
  .on('SAVE_SETTINGS', async (payload) => {
    await chrome.storage.sync.set({ settings: payload });
    return { saved: true };
  })
  .on('GET_TAB_INFO', async (payload, sender) => {
    return { tabId: sender.tab?.id, url: sender.tab?.url };
  });

Content Script Communication Bridge

For communication between the page's JS context and the extension:

// content.js — bridge between page world and extension
window.addEventListener('message', (event) => {
  if (event.source !== window) return;
  if (event.data?.source !== 'my-ext-page') return;

  // Forward to background
  chrome.runtime.sendMessage({
    type: 'FROM_PAGE',
    payload: event.data.payload
  }).then((response) => {
    // Send response back to page
    window.postMessage({
      source: 'my-ext-content',
      payload: response
    }, '*');
  });
});

// In a MAIN world script or injected <script>
window.postMessage({
  source: 'my-ext-page',
  payload: { action: 'getData', key: 'user' }
}, '*');

window.addEventListener('message', (event) => {
  if (event.data?.source === 'my-ext-content') {
    console.log('Response from extension:', event.data.payload);
  }
});

Broadcasting to All Content Scripts

async function broadcastToContentScripts(message) {
  const tabs = await chrome.tabs.query({});
  const results = await Promise.allSettled(
    tabs.map((tab) =>
      chrome.tabs.sendMessage(tab.id, message).catch(() => null)
    )
  );
  return results;
}

// Usage
broadcastToContentScripts({ type: 'SETTINGS_CHANGED', settings: newSettings });

Best Practices

  • Always return true from onMessage listeners when calling sendResponse asynchronously; otherwise the message channel closes before the response is sent.
  • Define a clear message schema with type fields to route messages and avoid ambiguity when multiple components communicate.
  • Validate sender.origin or sender.url in onMessageExternal handlers to prevent unauthorized web pages from interacting with your extension.

Core Philosophy

Messaging in a browser extension is inter-process communication, and it should be treated with the same rigor as a network protocol. Define a clear message schema with typed type fields, document the expected payloads, and validate inputs on the receiving end. Unstructured messaging — sending arbitrary objects without a routing mechanism — quickly becomes unmaintainable as the extension grows.

Prefer the invoke/handle pattern (sendMessage with response) over fire-and-forget (sendMessage without callback) for any operation where the sender needs to know the outcome. Fire-and-forget is appropriate for logging and analytics, but for data fetching, state changes, or user-facing actions, the sender should always know whether the operation succeeded. This mirrors the request/response pattern of HTTP and makes error handling explicit.

Every message crosses a trust boundary. Content scripts run in the context of arbitrary web pages, and their messages could be spoofed or manipulated by malicious page scripts (especially if the page can access the content script's DOM). Always validate the structure and content of messages in the background service worker before acting on them. Treat content script messages the same way you would treat user input on a server.

Anti-Patterns

  • Using a single generic message type for everything — sending { action: "do-stuff", data: ... } without a structured type system makes it impossible to route messages cleanly or add TypeScript type safety; define distinct message types.

  • Forgetting return true for async responses — when sendResponse is called inside an async callback, the message channel closes before the response is sent unless the listener explicitly returns true.

  • Exposing raw chrome.runtime.sendMessage to page scripts — allowing the page's JavaScript to trigger extension actions without validation creates a security hole; always validate sender origin and message structure.

  • Broadcasting to all tabs when only one is relevant — iterating over all tabs and sending messages to each when only the active tab needs the data wastes resources and can trigger errors on tabs where the content script is not injected.

  • Nesting message handlers deeply — handling dozens of message types in a single onMessage listener with nested if/else chains is unreadable; use a router pattern or handler map to dispatch by message type.

Common Pitfalls

  • Forgetting to return true in the onMessage listener for async responses — the port closes immediately and sendResponse silently fails.
  • Sending a message to a content script in a tab where the content script has not been injected — this throws an error; always handle the case where no receiver exists by wrapping in try/catch or checking tab status first.

Install this skill directly: skilldb add browser-extension-skills

Get CLI access →