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.

When each run_at value fires during page loaddocument_start runs before the DOM exists, document_end after parsing but before subresources, and document_idle once the page has settled.navigation commitspage idledocument_…no <body> …ParsingDOM being builtdocument_endDOM ready, images pendingdocument_idledefault, after loadsafe place to install CSS that must not flashsafe place to query and mutate the DOM
document_start gives you the earliest hook but an empty DOM — most UI injection belongs at document_idle.
 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.

A content script reporting a DOM event to the workerThe content script observes the page, sends a message that includes no DOM references, and the worker answers from storage.Page DOMContent scriptService workerMutationObserver firesserialise a summaryruntime.sendMessage(summary)wakes the workerhydrate setting…sendResponse(decision)apply the change to the DOM
Only structured-clonable data crosses the boundary — send a selector or a serialised summary, never a node.
 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.

6. A content script is a guest

Everything a content script does happens inside someone else’s application. The page’s developers did not design for it, do not test against it, and will change their markup without warning. That relationship should shape every decision about how a content script is written.

A good guest is small and quiet. It checks early whether there is anything for it to do on this page and exits immediately if not, so the cost on irrelevant pages is a few lines of parsing. It does not modify global objects the page relies on, does not add global CSS that restyles the page’s own elements, and does not steal focus or intercept keyboard shortcuts the page uses. When it adds UI, it mounts it in a shadow root on the document element, isolated in both directions, as described in injecting UI with shadow DOM without breaking the page. And when the page changes in a way the script does not understand, it degrades — stops quietly and logs the miss — rather than throwing errors into the page’s console or leaving half-rendered UI behind.

7. Resilience to markup changes

The most common reason a content-script feature stops working is not a bug in the extension but a change in the site. A class name is renamed, a container is restructured, a single-page application starts rendering asynchronously. Content scripts that depend on precise, deep selectors break with every such change.

Several habits make scripts more resilient. Prefer stable hooks — semantic elements, ARIA roles, data- attributes the site uses for its own testing, structured data in <script type="application/ld+json"> — over presentational class names. Keep all selectors for a site in one module, so a markup change is a one-file fix. Wait for elements to appear with a bounded observer rather than assuming they exist when the script runs. And log a structured “selector miss” when an expected element is not found, so a site change shows up in diagnostics before most users notice the feature has stopped working.

8. Single-page applications

Many large sites are single-page applications: the first page load runs your content script, and every later navigation replaces the view without loading a new document. A script written for traditional pages runs once and then appears to stop working as soon as the user clicks a link. Handling this requires detecting soft navigations — through the Navigation API where available, a main-world history hook, or webNavigation.onHistoryStateUpdated from the worker — and making initialisation idempotent, with complete teardown between views. The patterns are in handling single-page app navigation in content scripts.

9. Performance on the host page

A content script’s cost is paid on the host page’s main thread, where it competes with the site’s own rendering and input handling. Users attribute the resulting slowness to the site, and site owners sometimes attribute it to extensions in general, which is bad for everyone.

Keep the always-injected part tiny and load heavier code on demand. Scope MutationObservers to stable containers rather than the whole document, and coalesce their callbacks so a framework render triggers one scan rather than hundreds. Throttle scroll and resize work to animation frames and mark listeners passive. And measure: a Performance trace with and without the extension on a representative page shows exactly how much main-thread time the script adds, as described in measuring content script impact on page load.

10. Orphaned scripts after an update

When the extension updates, content scripts already running in open tabs keep running — but their connection to the extension is gone. Any call to chrome.runtime or chrome.storage throws “Extension context invalidated”. A script that does not expect this spams the page’s console and may leave UI that no longer works.

Detect the condition with a cheap check before extension calls, and when it is lost, tear down the script’s UI and listeners and stop. If the user was actively using the feature, a small, dismissible note offering to reload the page is enough. Newly loaded pages get the new script automatically; the orphaned ones only need to fail gracefully. The details are in fixing extension context invalidated after an update.

11. Choosing where the script runs

The last design decision is the set of pages the script runs on, and it has consequences beyond performance. Every match pattern appears in the install prompt, draws review attention, and — if broad — means the script is parsed into every page the user visits. Declaring narrowly in the manifest, and extending to user-chosen sites at runtime through optional host permissions, keeps both the prompt and the cost proportional to what the user actually uses. The mechanisms are compared in declarative vs programmatic content script registration.

12. Testing against the pages that matter

Content scripts are only as reliable as their tests against real markup, and real markup lives on sites you do not control. Keep a fixture of the relevant portion of each supported site’s HTML in the repository, serve it at the real URL during end-to-end tests through request routing, and assert that the script finds its targets and behaves correctly. When a user reports that a site broke, capture the new markup as an updated fixture first; the failing test then proves the diagnosis and guards the fix.

Complement the fixtures with a small, non-blocking job that loads a handful of the real sites on a schedule and checks for selector misses. It will occasionally fail for reasons unrelated to your code — a cookie banner, a regional redirect — which is exactly why it should not block releases. Its value is early warning: a site that changed its markup overnight shows up in the job’s report before it shows up in your reviews.

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.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.

Further guides in this topic

The guides below go deeper into specific content scripts and dom injection problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.