Mastering Content Script Injection in Browser Extensions
Imagine: you've developed a Chrome extension that highlights prices on an e-commerce site. The injected script works on initial load, but when the user navigates to the cart via pushState, the script stops responding—MutationObserver doesn't fire. That's a classic SPA pitfall. We encounter such cases on nearly every second project. In this article, we'll break down how to correctly inject an extension script, handle SPA navigation, organize data exchange with the background script, and avoid typical mistakes.
A content script (also called an injected script) is a mechanism for injecting code into a page. We integrate a content script—a JavaScript file that the browser injects into the target page's context in an isolated environment, as described in the Chrome Extensions documentation. This provides access to the DOM but not to the page's variables—both a protection and a limitation. Our experience: 5+ years on the market and over 30 successful projects in browser extension development. Content scripts are designed to modify the DOM of a target page. Our clients typically save 20-30% on development time by leveraging our established injection patterns.
How the Browser Loads a Content Script
Here’s a step-by-step process for injecting a content script:
- Declare scripts and their injection conditions in manifest.json (MV3).
- Set the
run_atparameter to control injection timing. - Optionally use dynamic injection via
chrome.scripting.executeScriptfrom the service worker.
In manifest.json, declare scripts and their injection conditions. The run_at parameter determines when the script is injected. Below is a comparison:
| run_at | Injection Moment | When to Use |
|---|---|---|
document_start |
Before DOM built | To intercept early requests |
document_end |
DOM ready, resources still loading | To modify structure before rendering |
document_idle |
After DOMContentLoaded | Safe default for most tasks |
{ "manifest_version": 3, "content_scripts": [ { "matches": ["https://*.example.com/*"], "js": ["content/injected.js"], "css": ["content/injected.css"], "run_at": "document_idle", "world": "ISOLATED" } ] } For dynamic injection (from service worker or on demand), use chrome.scripting.executeScript:
// background/service-worker.js chrome.action.onClicked.addListener(async (tab) => { await chrome.scripting.executeScript({ target: { tabId: tab.id, allFrames: false }, files: ['content/injected.js'], world: 'ISOLATED' }); }); Comparison of ISOLATED and MAIN World
| world | DOM Access | Page JS Access | Isolation | When to Use |
|---|---|---|---|---|
| ISOLATED | Full | None | High | Default |
| MAIN | Full | Full | Low | Monkey-patching, API interception |
Using ISOLATED world is 10x safer than MAIN world for most tasks, as it prevents accidental variable conflicts. world: 'MAIN' grants access to the page's variables but sacrifices isolation—use only when truly needed (e.g., intercepting native API calls).
Why Content Script Fails on SPA Pages?
On client-side navigation, the browser does not reload the content script. A MutationObserver on the
let lastUrl = location.href; const urlObserver = new MutationObserver(() => { if (location.href !== lastUrl) { lastUrl = location.href; onNavigate(location.href); } }); urlObserver.observe(document.querySelector('title') ?? document.head, { subtree: true, characterData: true, childList: true }); Working with the DOM
An injected script sees the full DOM, including Shadow DOM. For dynamic content (SPA), a MutationObserver is mandatory. Example: highlighting all prices on a page:
function highlightPrices() { const walker = document.createTreeWalker( document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { return /\$[\d,]+\.?\d{0,2}/.test(node.textContent) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } } ); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); nodes.forEach(node => { const span = document.createElement('span'); span.innerHTML = node.textContent.replace( /(\$[\d,]+\.?\d{0,2})/g, '<mark class="ext-price-highlight">$1</mark>' ); node.parentNode.replaceChild(span, node); }); } const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.addedNodes.length > 0) highlightPrices(); } }); observer.observe(document.body, { childList: true, subtree: true }); highlightPrices(); On a recent e-commerce project, we reduced DOM re-processing time from 120ms to 30ms per navigation by batching updates in requestAnimationFrame and using requestIdleCallback for tree walking. This optimization resulted in a 75% reduction in processing time.
Communicating with the Background Service Worker
The content script cannot directly access chrome.tabs, so use messaging. For bidirectional streaming, establish a port:
// content/injected.js const port = chrome.runtime.connect({ name: 'content-stream' }); port.onMessage.addListener((msg) => { if (msg.type === 'DATA_CHUNK') appendChunk(msg.data); }); port.postMessage({ type: 'START_STREAM', url: location.href }); The typical scenario is a one-shot request via chrome.runtime.sendMessage. The background must call sendResponse with true if the response is asynchronous.
How to Avoid Style Conflicts When Injecting a Content Script?
Two approaches: Shadow DOM for complete UI isolation (provides 100% style isolation), or CSS with high specificity and unique class prefixes (e.g., ext-). If the page enforces strict CSP, inject styles via chrome.scripting.insertCSS from the service worker.
Passing Data from the Page into the Content Script
Since JavaScript contexts are isolated, use window.postMessage from the page script (or MAIN world) and listen in the content script with event.source === window verification.
What’s Included in the Work
Turnkey content script development includes:
- Configure manifest and script declaration
- Handle SPA navigation and dynamic content
- Integrate with background service worker via messaging
- Ensure style isolation and CSP compliance
- Test on target pages (up to 10 sites)
- Provide documentation and support
Typical Problems and Solutions
A frequent issue: content script stops after SPA transition. Solution: MutationObserver on
chrome.scripting.insertCSS. For performance, run heavy DOM operations in requestAnimationFrame and idle-time tasks in requestIdleCallback.Additional complexities
- If the page uses Shadow DOM, ensure your content script correctly penetrates open shadow roots. Closed roots are inaccessible.
- To inject into iframes, set
allFrames: truein the script configuration.
With 5+ years on the market and over 30 projects delivered, we bring proven expertise in browser extension development. We guarantee reliable extension performance in Chrome, Edge, and Opera. Contact us for a project assessment or to order a custom content script. Get a free consultation—our engineers will help implement a content script of any complexity.







