Quick answer
A content script reads or changes a web page’s DOM while running in an execution environment isolated from the site’s JavaScript by default. The DOM is shared; JavaScript variables are not. You can inspect an element’s computed style without gaining access to the site’s private application store.
Choose static content_scripts when a feature should run automatically on known URL patterns. Choose scripting.executeScript when the user invokes a page tool. Dynamic registration is useful when the set of automatic sites changes. These strategies control injection; they do not remove the need to handle navigation, frames, repeated installation, and hostile page data.
Practical implementation steps
Define the document scope
A script in the top frame cannot automatically inspect a cross-origin iframe. all_frames is a deliberate choice and every frame must satisfy applicable access rules. Shadow roots also need explicit handling; closed roots are not ordinary descendants of document. State those limits in the product rather than silently reporting a complete-page result.
Make installation repeat-safe
Users can invoke an on-demand script twice. Without a guard, each invocation can add another listener or overlay. Keep an extension-specific marker in the isolated world and expose a cleanup operation for anything the script installs. Unregistering a dynamically registered script stops future injection; it does not remove code or styles already injected into a document.
Handle page changes proportionately
A single-page application may replace a region without reloading the document. Event delegation is often enough for click inspection because it does not retain each element. For scanning, use a bounded observer and debounce batches rather than rescanning the entire DOM on every mutation. Disconnect observers when the feature is disabled.
Treat page data as untrusted
Return compact, plain records. Do not ask a privileged worker to fetch an arbitrary URL supplied by the page. Render extracted text with text APIs. If you change page content, record what you own and restore only where the site has not independently edited it. LocaleStress explicitly documents top-frame light-DOM scope and restoration conflicts, useful examples of honest limits rather than universal coverage claims.
Implementation example
A static script can answer a narrowly defined selection request. The global marker belongs to this extension’s isolated world, so a site’s JavaScript global with the same spelling is not a shared variable.
if (!globalThis.__selectionReaderInstalled) {
globalThis.__selectionReaderInstalled = true;
chrome.runtime.onMessage.addListener((message, _sender, respond) => {
if (message?.type !== "selection/read") return;
respond({ text: String(window.getSelection() ?? "").slice(0, 2000) });
});
}
An extension page can call the script with chrome.tabs.sendMessage(tabId, { type: "selection/read" }). Catch rejection when the tab has no receiving script, has navigated, or is inaccessible. The 2,000-character limit is an example application bound, not a browser API limit. A content script still needs runtime validation for messages that perform mutations or expose sensitive state.
Use a file for a larger on-demand script so imports and helpers are packaged deliberately. If you inject a function instead, it cannot capture the calling context’s variables; pass serializable arguments.
Tradeoffs and alternatives
An isolated-world script is the safer default for DOM inspection. MAIN-world execution is appropriate only for a specific integration with site JavaScript; the site can observe or interfere with that code. Keep any bridge tiny and regard its responses as untrusted.
Automatic scripts trade convenience for broader ongoing access. Explicit invocation reduces that exposure but requires a clear unsupported-page state. A tool may support only a top-frame sample; that can be useful if reports describe the scope accurately.
Common mistakes
- Assuming an isolated world means the DOM cannot be changed by the site.
- Returning live DOM nodes through extension messages.
- Reinjecting without removing duplicate observers and listeners.
- Reporting an iframe or closed shadow root as scanned when it was skipped.
- Restoring old text over a later application edit.
Production checklist
- Match patterns cover intended sites without unnecessary origins.
- Repeated invocation produces one active feature instance.
- SPA changes, navigation, and extension reloads have recovery behavior.
- Disabled features remove installed listeners, observers, and owned UI.
- Extracted data is bounded and rendered as text.
- Reports state frame and shadow-DOM coverage honestly.
FAQ
Can a content script use every extension API?
No. The available API subset differs from extension pages. Use a narrow message to the worker when a privileged operation is genuinely needed.
Does document_idle wait for every SPA component?
No. It describes initial injection timing, not completion of future application rendering. Observe or query the specific dynamic region.
What should a first implementation support?
One explicit document scope and one repeat-safe operation. Add frames, shadow roots, and automatic observation only after you can explain their access, cleanup, and result semantics.
Primary sources
Related guides and examples
Need this implemented in a real product?
Explore relevant product case studies or share the context for a web application, Chrome extension, API, or SaaS build.