ESC

Type to search the knowledge base.

Browser Extension Messaging Basics

How content scripts, background/service workers, and pages talk: runtime messaging, ports, and security boundaries.

advanced3 min read
  • browser
  • extensions
  • messaging
  • chrome-api

Browser extensions are multi-process apps. A content script shares the page’s DOM world (with isolation rules) but not the page’s JS globals. A background service worker (Manifest V3) owns long-lived logic. A popup is a short-lived document. Messaging is how these pieces cooperate without sharing memory.

Docs: Chrome extension messaging, MDN webextension messaging.

The isolation map

Context Sees page DOM? Sees page JS? Extension APIs
Page script Yes Yes No
Content script Yes (usually) No (isolated world) Limited
Service worker No No Full
Popup / options Own document Own JS Full

“I set window.foo in the page and the content script can’t read it” is expected. Use DOM custom events, window.postMessage, or the official messaging APIs deliberately.

One-shot messages

// content-script.js
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (response) => {
  console.log(response);
});

// service-worker.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type !== 'GET_STATUS') return;
  sendResponse({ ok: true, tabId: sender.tab?.id });
  // return true if you will sendResponse asynchronously
});

Always validate message.type and shape. Treat messages as untrusted input even inside your extension — a compromised page or buggy script can still send noise if you exposed a bridge.

Long-lived ports

const port = chrome.runtime.connect({ name: 'watch' });
port.postMessage({ type: 'subscribe', topic: 'cart' });
port.onMessage.addListener((msg) => {
  /* stream updates */
});

Ports suit streams and multi-step flows. Handle port.onDisconnect — service workers stop, popups close, tabs navigate.

Talking to the page

Content scripts can inject a script tag (carefully) or use postMessage:

// content script
window.addEventListener('message', (event) => {
  if (event.source !== window) return;
  if (event.data?.source !== 'my-extension') return;
  // handle
});

window.postMessage({ source: 'my-extension', type: 'READY' }, '*');

Prefer a strict origin and a shared secret/token pattern when the page is first-party and controlled. Never accept eval of message payloads.

External web pages → extension

externally_connectable (Chrome) lets listed origins call chrome.runtime.sendMessage(extensionId, …). Whitelist origins tightly. This is a common phishing/social-engineering surface if misconfigured.

Manifest V3 footguns

  1. Service workers are ephemeral — don’t keep critical state only in SW memory; use chrome.storage.
  2. Alarms and events rehydrate the worker; design handlers to be restart-safe.
  3. Host permissions are required for cross-origin fetches from the SW.
  4. chrome.scripting.executeScript needs permission and a clear target.

Interview out-loud

“Content scripts are isolated from page JS; they message the service worker with runtime.sendMessage or ports. Validate message shapes, handle disconnect, and never trust page postMessage without origin and schema checks.”

Minimal architecture that scales

  • Content script: DOM read/write only; no secrets.
  • Service worker: orchestrates network, storage, alarms.
  • Popup: ephemeral UI; hydrate from chrome.storage every open.
  • Messages: versioned (v: 1) with Zod-like runtime checks.
function assertMsg(msg) {
  if (!msg || msg.v !== 1 || typeof msg.type !== 'string') {
    throw new Error('bad message');
  }
}

Never inject world-main scripts that read extension IDs into untrusted pages without a solid threat model — page JS can listen and spoof UI.

Further reading

Related guides