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.
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.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.
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.
- Content Script run_at Timing Explained — What document_start, document_end and document_idle actually mean in MV3 — what exists at each point, which one to choose, and how to wait for the DOM you need.
- Declarative vs Programmatic Content Script Registration — Choose between manifest content_scripts, runtime registration and one-shot injection in MV3 — timing, permissions, store review and what each one costs on a busy page.
- Handling Single-Page App Navigation in Content Scripts — Detect soft navigations in SPAs from an MV3 content script — history API patching, the Navigation API, webNavigation.onHistoryStateUpdated, and idempotent re-initialisation.
- Injecting UI with Shadow DOM Without Breaking the Page — Mount extension UI into a host page using a closed shadow root — style isolation both ways, stacking context, event retargeting and surviving the page’s own DOM churn.
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.