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.
Prerequisites checklist
scriptingpermission — required forchrome.scripting.executeScript()andchrome.scripting.insertCSS().- Host permissions or
activeTab— the manifest must declare matching host patterns (e.g.,"https://*.example.com/*") or you must hold theactiveTabgrant from a user gesture. content_scriptskey — 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:orfiles:must ship inside the extension package. web_accessible_resources— required if your content script loads extension assets (images, CSS) viachrome.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 to design around
- No remote code:
eval(),new Function(string), and<script src="https://...">injection are all blocked by the extension’s default CSP. - No
chrome.tabsin content scripts: usechrome.runtime.sendMessageto ask the service worker. document_startruns 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'blockschrome.*: you cannot use extension APIs inside a MAIN world function — proxy everything through the ISOLATED side.all_frames: trueinjects 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
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
chrome.scripting.executeScript | Chrome 88+ | browser.scripting, FF 101+ | Safari 17+ |
world: 'MAIN' | Chrome 102+ | Firefox 128+ | Not supported |
run_at: document_start | Reliable | Reliable | Unreliable on heavy pages |
Shadow DOM mode: 'closed' | Full support | Full support | Safari 14+ |
match_about_blank | Supported | Supported | Partial |
all_frames in manifest | Supported | Supported | Supported |
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.
Related
- Best practices for content script isolation in MV3 — secure bridging, CustomEvent patterns, prototype pollution defence.
- Injecting content scripts into dynamic iframes —
all_frames,frameIdtargeting, cross-origin limits. - Service Worker Fundamentals — lifecycle and eviction constraints that govern when you can call
chrome.scripting. - Extension Popup Architecture — coordinating popup state with content script events.
- Up to the Manifest V3 Architecture & Extension Lifecycle overview.