Content Scripts & DOM Injection

Inject JavaScript and CSS into web pages with MV3 content scripts — isolated worlds, declarative vs programmatic injection, run_at timing, MAIN world risks, and Shadow DOM UI patterns.

Without content scripts your extension is blind to the page — it cannot read the DOM, intercept user interaction, or insert UI elements. The invisible trap: content scripts do not share a JavaScript runtime with the host page. They run in an isolated world that shares only the DOM tree, which means window globals, prototype chains, and event listeners from the host are completely separate. Get that boundary wrong and you spend hours debugging undefined reads and CSP errors. This guide covers the full injection model as part of the Manifest V3 Architecture & Extension Lifecycle overview.

Isolated-world model for MV3 content scriptsA browser tab hosts three stacked layers: the host page JavaScript context (MAIN world), the extension isolated world, and the shared DOM. Arrows show one-way DOM access from both worlds and the CustomEvent/postMessage bridge between them.Host Page (MAIN world)window · prototype chain · page globalsContent Script (ISOLATED world)chrome.* APIs · own globals · own windowShared DOMdocument · elements · computed stylesread/writeread/writeWorld BridgeCustomEvent / window.postMessagestructured-clone payloadvalidate origin before trustingor: chrome.scripting world:'MAIN'(Chrome 102+, Firefox 128+)

Prerequisites checklist

  • scripting permission — required for chrome.scripting.executeScript() and chrome.scripting.insertCSS().
  • Host permissions or activeTab — the manifest must declare matching host patterns (e.g., "https://*.example.com/*") or you must hold the activeTab grant from a user gesture.
  • content_scripts key — only for declarative injection; programmatic injection does not need it.
  • Bundled assets only — remote code execution is banned in MV3. Every JS file referenced in js: or files: must ship inside the extension package.
  • web_accessible_resources — required if your content script loads extension assets (images, CSS) via chrome.runtime.getURL().

1. Declarative injection via manifest.json

Declarative registration is the right default for any extension that targets a consistent set of URL patterns. The browser handles injection timing automatically — no code in the service worker required.

 1{
 2  "manifest_version": 3,
 3  "name": "Highlighter",
 4  "permissions": ["storage"],
 5  "content_scripts": [
 6    {
 7      "matches": ["https://*.example.com/*", "https://docs.example.org/*"],
 8      "js": ["content/highlighter.js"],
 9      "css": ["content/highlighter.css"],
10      "run_at": "document_idle",   // after DOMContentLoaded + subresource quiet period
11      "all_frames": false          // top-level frame only (default)
12    }
13  ]
14}

Execution context: manifest.json, evaluated by the browser at install time. run_at accepts three values: document_start (before any DOM exists), document_end (after DOM is ready but scripts may still run), document_idle (after DOMContentLoaded and a short quiet period — usually the correct default). Firefox respects all three; Safari respects them but document_start can misbehave on heavy pages.

2. Programmatic injection via chrome.scripting

Use chrome.scripting.executeScript() when injection must be conditional — gated on user action, runtime data, or feature flags — rather than URL-pattern-driven.

 1// service-worker.js (background)
 2chrome.action.onClicked.addListener(async (tab) => {
 3  if (!tab.id || !tab.url?.startsWith("https://")) return;
 4
 5  try {
 6    const [result] = await chrome.scripting.executeScript({
 7      target: { tabId: tab.id },
 8      files: ["content/highlighter.js"],
 9    });
10    console.log("Injection result:", result.result);
11  } catch (err) {
12    // NotAllowedError: user has not granted host permission
13    console.error("Injection failed:", (err as Error).message);
14  }
15});

Execution context: Service worker background context. chrome.scripting is unavailable in content scripts themselves. files must be extension-local paths; passing an arbitrary URL throws. Firefox requires browser.scripting (or a polyfill) — the chrome.scripting alias is not universally available on Firefox MV3 yet. Safari supports browser.scripting.executeScript() from Safari 17.

3. ISOLATED vs MAIN world

The world parameter is one of the highest-risk knobs in MV3. Understand it before touching it.

ISOLATED world (default): Your script gets its own window object, its own prototype chain, and full access to chrome.* APIs. The host page cannot read or overwrite your globals. This is what you should use for 95 % of injection tasks.

MAIN world: Your script runs inside the host page’s JavaScript context. You can read window.dataLayer, call page-defined functions, and listen to events fired in the page’s own runtime. The price: you lose all chrome.* API access, you are exposed to prototype pollution from the host, and you must never trust host-page data without sanitising it.

1// Use MAIN world only to read page-level globals you cannot reach from ISOLATED
2await chrome.scripting.executeScript({
3  target: { tabId },
4  world: "MAIN",
5  func: () => {
6    // No chrome.* APIs available here
7    return window.__APP_CONFIG__?.apiEndpoint ?? null;
8  },
9});

Execution context: Service worker initiates; the func body runs in the tab’s MAIN JavaScript context. Chrome 102+ only. Firefox added world: 'MAIN' in Firefox 128. Safari does not support world: 'MAIN'; bridge data with window.postMessage or CustomEvent from an ISOLATED content script instead.

Communicating between MAIN and ISOLATED worlds happens through the DOM event system — not through any extension API. Prefer CustomEvent over window.postMessage when the source is your own code, and always validate payloads before acting on them. The full threat-surface analysis and secure bridging patterns live in best practices for content script isolation in MV3.

4. Sending messages back to the service worker

Content scripts have access to chrome.runtime.sendMessage() and chrome.runtime.connect() but cannot call chrome.tabs or initiate fetches to arbitrary origins (cross-origin XHR from a content script is blocked by CORS). Route remote requests through the service worker.

 1// content/highlighter.js (ISOLATED world)
 2async function reportSelection(selectedText: string) {
 3  const response = await chrome.runtime.sendMessage({
 4    type: "SELECTION",
 5    text: selectedText,
 6    url: location.href,
 7  });
 8  if (response?.saved) {
 9    showToast("Saved");
10  }
11}
12
13document.addEventListener("mouseup", () => {
14  const sel = window.getSelection()?.toString().trim();
15  if (sel) reportSelection(sel);
16});

Execution context: Content script in ISOLATED world. chrome.runtime.sendMessage() serialises the payload through structured clone and delivers it to the service worker’s chrome.runtime.onMessage listener. Firefox and Safari support the same pattern under browser.runtime. Do not use long-lived sendMessage for fire-and-forget updates where message ordering matters — prefer chrome.runtime.connect() for that pattern.

5. Injecting UI with Shadow DOM

Appending plain elements directly to document.body leaks them into the host page’s stylesheet cascade. The host page’s CSS resets, frameworks, or wildcard selectors will style your elements unintentionally. Attach a Shadow DOM root instead.

 1// content/ui-injector.js (ISOLATED world)
 2function mountExtensionPanel() {
 3  const host = document.createElement("div");
 4  host.id = "mv3-ext-panel-host";
 5  document.body.appendChild(host);
 6
 7  const shadow = host.attachShadow({ mode: "closed" });
 8
 9  const link = document.createElement("link");
10  link.rel = "stylesheet";
11  link.href = chrome.runtime.getURL("content/panel.css");
12  shadow.appendChild(link);
13
14  const container = document.createElement("div");
15  container.className = "panel-root";
16  shadow.appendChild(container);
17
18  return container; // hand off to your UI framework
19}

Execution context: Content script in ISOLATED world. chrome.runtime.getURL() requires the CSS file to be listed in web_accessible_resources with the correct matches entry. mode: "closed" prevents host-page JavaScript from reaching into the shadow root via element.shadowRoot. Firefox and Chrome both support closed shadow roots; Safari has historically been slower to adopt some Shadow DOM v1 features but has supported the core API since Safari 14.

MV3 Constraints box

  • No remote code: eval(), new Function(string), and <script src="https://..."> injection are all blocked by the extension’s default CSP.
  • No chrome.tabs in content scripts: use chrome.runtime.sendMessage to ask the service worker.
  • document_start runs before <head>: do not query elements that do not exist yet.
  • Service worker is ephemeral: do not assume the worker that injected your script is still alive when your script calls back.
  • world: 'MAIN' blocks chrome.*: you cannot use extension APIs inside a MAIN world function — proxy everything through the ISOLATED side.
  • all_frames: true injects into every sub-frame: performance cost and cross-origin frame restrictions apply; see injecting content scripts into dynamic iframes for frameId targeting and the cross-origin limits.

Cross-browser notes

FeatureChrome / EdgeFirefoxSafari
chrome.scripting.executeScriptChrome 88+browser.scripting, FF 101+Safari 17+
world: 'MAIN'Chrome 102+Firefox 128+Not supported
run_at: document_startReliableReliableUnreliable on heavy pages
Shadow DOM mode: 'closed'Full supportFull supportSafari 14+
match_about_blankSupportedSupportedPartial
all_frames in manifestSupportedSupportedSupported

Firefox carries forward some MV2 permission-handling behaviour: a content script can be granted to <all_urls> without a browser-action click, whereas on Chrome this requires a broad host permission declared at install time. Safari enforces per-site permission prompts that may prevent injection even with correct manifest declarations.