Content Scripts: Patterns and Pitfalls
Master content script development in Chrome extensions. Learn injection strategies, DOM manipulation patterns, CSS isolation, performance optimization, and common pitfalls.
Table of Contents
Content scripts are where your extension meets the web page. They run in the context of a visited page, with direct access to the DOM but isolated from the page's own JavaScript. This boundary is both the greatest strength and the most frequent source of bugs in Chrome extension development. Understanding how to inject code, manipulate the DOM safely, isolate your styles, handle single-page applications, and communicate with your service worker separates production-quality extensions from ones that break on every other website.
This guide covers every pattern and pitfall you need to know, from manifest-declared injection to programmatic scripting, Shadow DOM isolation, MutationObserver strategies, and the performance traps that will tank your extension's reviews if you ignore them.
2
Execution Worlds
ISOLATED (default) and MAIN world contexts
Unlimited
Max Static Rules
Manifest-declared content scripts have no cap
Full
DOM Access
Content scripts share the page DOM directly
100%
JS Isolation
ISOLATED world cannot access page JS variables
Manifest vs. Programmatic Injection#
There are two fundamentally different ways to get your content script onto a page: declaring it in the manifest, or injecting it programmatically from the service worker. Each approach has distinct tradeoffs, and most production extensions use both.
Manifest-Declared Content Scripts#
The simplest approach. You declare URL patterns in your manifest.json, and Chrome injects the scripts automatically whenever the user navigates to a matching page:
{
"content_scripts": [
{
"matches": ["https://*.github.com/*"],
"js": ["content/github-enhancer.js"],
"css": ["content/github-styles.css"],
"run_at": "document_idle",
"world": "ISOLATED"
}
]
}The key fields here are run_at and world. The run_at property controls timing: document_start fires before any page scripts execute (useful for intercepting network requests or injecting polyfills), document_end fires after the DOM is parsed but before images and subframes finish loading, and document_idle (the default) fires after the page is mostly loaded. Most of the time, document_idle is what you want — it avoids competing with the page's initial rendering and ensures the elements you need are already in the DOM.
The world property determines the JavaScript execution context. The ISOLATED world (default) gives your script its own global scope — you cannot access variables defined by the page, and the page cannot access yours. The MAIN world runs your script as if it were part of the page itself, sharing the same window object. The MAIN world is powerful but dangerous: the page can detect your code, override functions you depend on, and your extension's security boundary collapses.
Programmatic Injection#
When you need fine-grained control over when and where scripts execute, use chrome.scripting.executeScript from your service worker:
// background.ts — inject on demand from a context menu click
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== "analyze-page" || !tab?.id) return;
try {
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ["content/analyzer.js"],
world: "ISOLATED",
});
const pageData = results[0]?.result;
if (pageData) {
await chrome.storage.session.set({ lastAnalysis: pageData });
}
} catch (err) {
// Tab might be a chrome:// URL or otherwise restricted
console.error("Injection failed:", err);
}
});Programmatic injection requires the scripting permission and either activeTab or explicit host permissions. The activeTab approach is strongly preferred because it only grants access to the tab the user actively clicked — the Chrome Web Store review team looks favorably on minimal permissions. For a deeper understanding of how permissions affect your review outcome, see our guide on Chrome extension permissions explained.
You can also inject functions directly instead of files, which is useful for small one-off operations:
// Inject an inline function that returns structured data
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: false },
func: () => {
const meta: Record<string, string> = {};
document.querySelectorAll('meta[name], meta[property]').forEach((el) => {
const key = el.getAttribute("name") || el.getAttribute("property");
const value = el.getAttribute("content");
if (key && value) meta[key] = value;
});
return meta;
},
});- Use manifest injection for always-on features that must run on every matching page
- Use programmatic injection for on-demand features triggered by user action
- Prefer activeTab permission over broad host permissions for programmatic injection
- Set run_at to document_idle unless you have a specific reason for earlier injection
- Inject into every page with <all_urls> unless your extension genuinely needs it
- Use MAIN world injection as a default — it breaks your security boundary
- Forget to handle injection errors for restricted pages (chrome://, web store, etc.)
- Inject large bundles at document_start — it delays the page's own first paint
DOM Manipulation Patterns#
Once your content script is running, you are working with the live DOM of someone else's webpage. This is inherently fragile. Pages update their markup between deploys. Class names generated by CSS-in-JS frameworks are non-deterministic. Elements load asynchronously. The patterns below will help you write DOM code that does not shatter every time the target site pushes an update.
Querying Elements Defensively#
Never assume an element exists. Every querySelector call can return null, and every assumption about DOM structure can be wrong:
function findPriceElement(): HTMLElement | null {
// Try multiple selectors from most specific to least
const selectors = [
'[data-testid="product-price"]',
'[itemprop="price"]',
'.product-price .amount',
'#price-display',
];
for (const selector of selectors) {
const el = document.querySelector<HTMLElement>(selector);
if (el?.textContent?.trim()) return el;
}
return null;
}
function extractPrice(): number | null {
const el = findPriceElement();
if (!el) return null;
const raw = el.textContent?.replace(/[^0-9.]/g, "");
const parsed = raw ? parseFloat(raw) : NaN;
return Number.isFinite(parsed) ? parsed : null;
}The cascading selector strategy above is essential. Data attributes like data-testid are the most stable (they are usually maintained for the site's own tests), semantic attributes like itemprop are second-best, and class-based selectors are the most fragile. Design your selectors to degrade gracefully when the page structure changes.
Using MutationObserver for Dynamic Content#
Modern web pages rarely have all their content in the initial HTML. React, Vue, Angular, and every other SPA framework render content asynchronously. You need MutationObserver to react when relevant elements appear:
function waitForElement(
selector: string,
timeout = 10000
): Promise<HTMLElement> {
return new Promise((resolve, reject) => {
// Check if it already exists
const existing = document.querySelector<HTMLElement>(selector);
if (existing) {
resolve(existing);
return;
}
const timer = setTimeout(() => {
observer.disconnect();
reject(new Error(`Timeout waiting for ${selector}`));
}, timeout);
const observer = new MutationObserver((mutations, obs) => {
const el = document.querySelector<HTMLElement>(selector);
if (el) {
clearTimeout(timer);
obs.disconnect();
resolve(el);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
}
// Usage
try {
const priceEl = await waitForElement('[data-testid="product-price"]');
processPriceElement(priceEl);
} catch {
console.debug("Price element never appeared — page layout may have changed");
}Handling Single-Page Applications#
SPAs are the hardest environment for content scripts. The user navigates between "pages" but no real page load occurs — just JavaScript swapping DOM nodes. Your content script loads once and must detect these soft navigations. There are several signals you can listen for:
// Detect SPA navigations via multiple signals
function onSPANavigation(callback: (url: string) => void): void {
let lastUrl = location.href;
// 1. History API interception (covers pushState/replaceState)
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);
function checkNavigation(): void {
if (location.href !== lastUrl) {
lastUrl = location.href;
callback(lastUrl);
}
}
// This only works in MAIN world — for ISOLATED world, use the observer approach
if (typeof window.__extensionNavHandler === "undefined") {
window.addEventListener("popstate", checkNavigation);
}
// 2. URL polling fallback (works in ISOLATED world)
const urlObserver = setInterval(checkNavigation, 500);
// 3. DOM title changes as a signal
const titleObserver = new MutationObserver(checkNavigation);
const titleEl = document.querySelector("title");
if (titleEl) {
titleObserver.observe(titleEl, { childList: true, characterData: true });
}
// Cleanup when extension context invalidates
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "CLEANUP") {
clearInterval(urlObserver);
titleObserver.disconnect();
}
});
}URL polling at 500ms might feel ugly, but it is the most reliable cross-site approach for the ISOLATED world. The Navigation API (navigation.addEventListener("navigate", ...)) is a cleaner modern alternative, but it only fires in the MAIN world.
The real challenge with SPAs is cleanup. When the user navigates to a new "page" within the SPA, any UI elements you injected into the previous view are now stale. You must track your injected elements and tear them down on each navigation before re-injecting.
CSS Isolation with Shadow DOM#
Injecting UI into a webpage without your styles leaking out — or the page's styles leaking in — is one of the trickiest problems in content script development. The answer is Shadow DOM.
Creating an Isolated UI Container#
function createIsolatedUI(): {
host: HTMLElement;
root: ShadowRoot;
container: HTMLDivElement;
} {
const host = document.createElement("div");
host.id = "my-extension-root";
// Prevent page styles from affecting the host element
host.style.cssText = `
all: initial !important;
position: fixed !important;
top: 16px !important;
right: 16px !important;
z-index: 2147483647 !important;
font-family: system-ui, -apple-system, sans-serif !important;
`;
const root = host.attachShadow({ mode: "closed" });
// Inject styles inside the shadow root
const style = document.createElement("style");
style.textContent = `
.panel {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
width: 320px;
max-height: 480px;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
color: #1a202c;
font-size: 14px;
line-height: 1.5;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 600;
font-size: 15px;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: #718096;
font-size: 18px;
line-height: 1;
}
.close-btn:hover { color: #2d3748; }
`;
const container = document.createElement("div");
container.className = "panel";
root.appendChild(style);
root.appendChild(container);
document.body.appendChild(host);
return { host, root, container };
}Using mode: "closed" is important. A closed shadow root prevents the host page from accessing your shadow DOM via host.shadowRoot, which means page scripts cannot tamper with your injected UI. Some developers use mode: "open" during development for easier debugging, which is fine — just switch to closed before shipping.
The all: initial !important on the host element is the critical line. Without it, inherited properties from the page (like font-size, color, line-height, or even display: none from an aggressive CSS reset) will bleed through and break your UI. The !important declarations on positioning are necessary because many pages use aggressive selectors that target div elements globally.
Message Passing Between Content Script and Service Worker#
Content scripts cannot use most chrome.* APIs directly. They need to relay requests to the service worker via message passing. Here is a typed, robust pattern:
// shared/messages.ts — shared type definitions
interface MessageMap {
FETCH_DATA: { url: string; headers?: Record<string, string> };
SAVE_ITEM: { item: { title: string; url: string; timestamp: number } };
GET_SETTINGS: undefined;
}
interface ResponseMap {
FETCH_DATA: { data: unknown; status: number };
SAVE_ITEM: { success: boolean };
GET_SETTINGS: { syncInterval: number; notificationsEnabled: boolean };
}
type Message<T extends keyof MessageMap> = {
type: T;
payload: MessageMap[T];
};
// content-script.ts — send typed messages
async function sendMessage<T extends keyof MessageMap>(
type: T,
payload: MessageMap[T]
): Promise<ResponseMap[T]> {
const response = await chrome.runtime.sendMessage({ type, payload });
if (response?.error) {
throw new Error(response.error);
}
return response as ResponseMap[T];
}
// Usage in content script
async function saveCurrentPage(): Promise<void> {
const item = {
title: document.title,
url: location.href,
timestamp: Date.now(),
};
try {
const { success } = await sendMessage("SAVE_ITEM", { item });
if (success) showToast("Saved successfully");
} catch (err) {
// Service worker might have been terminated
showToast("Save failed — try again");
}
}
// background.ts — handle messages with type narrowing
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
const { type, payload } = message;
if (type === "FETCH_DATA") {
handleFetchData(payload)
.then(sendResponse)
.catch((err) => sendResponse({ error: err.message }));
return true; // Keep the message channel open for async response
}
if (type === "SAVE_ITEM") {
handleSaveItem(payload, sender.tab)
.then(sendResponse)
.catch((err) => sendResponse({ error: err.message }));
return true;
}
});The return true in the message listener is critical and a common source of bugs. Without it, the message port closes immediately and the content script's sendMessage resolves with undefined. Every async handler must return true from the listener to signal that the response will be sent asynchronously.
For more on service worker patterns that complement this message-passing approach, see Background Service Workers: A Deep Dive.
Performance Pitfalls#
Content scripts run in the user's browser on every page that matches your injection rules. A poorly optimized content script does not just make your extension slow — it makes every website the user visits slower. This is the fastest path to one-star reviews.
The Expensive Operations#
Forced synchronous layouts (layout thrashing): Reading a layout property (like offsetHeight) immediately after writing one (like setting style.width) forces the browser to recalculate layout synchronously. Batch all reads before writes.
Unbounded MutationObservers: As mentioned above, a subtree: true observer on document.body fires on every DOM change. On a Twitter feed or Gmail inbox, that can mean thousands of callbacks per second. Always scope observers as narrowly as possible and disconnect when done.
Heavy script injection at document_start: Injecting a large bundle before the page has parsed means your code competes with the page's critical rendering path. This directly impacts Core Web Vitals. Unless you are intercepting network requests or modifying the page before first paint, use document_idle.
Polling the DOM: Using setInterval to check for elements is a last resort. It wastes CPU cycles when nothing has changed and introduces latency when something does. Use MutationObserver instead, and only fall back to polling for the specific case of URL change detection in SPAs.
Checklist
- Scope MutationObservers to the narrowest possible container
- Disconnect observers when they are no longer needed
- Batch DOM reads before DOM writes to avoid layout thrashing
- Use document_idle injection unless you need earlier timing
- Debounce MutationObserver callbacks if they process multiple changes
- Lazy-load heavy dependencies — do not bundle everything into the content script
- Profile with Chrome DevTools Performance tab on real target pages
- Test on slow hardware — your M3 MacBook is not representative of your users' machines
Lazy Loading and Code Splitting#
Your content script does not need to contain all your logic upfront. Load heavy modules only when they are actually needed:
// content-script.ts — lightweight entry point
function init(): void {
const targetEl = document.querySelector('[data-product-id]');
if (!targetEl) return; // Not a product page, bail early
// Only load the heavy analysis module when needed
targetEl.addEventListener("mouseenter", async () => {
const { analyzeProduct } = await import("./analyzers/product.js");
const result = await analyzeProduct(targetEl);
renderTooltip(targetEl, result);
}, { once: true });
}
init();Dynamic import() works in content scripts when your manifest declares "type": "module" for the service worker (which enables ES modules across your extension). Keep your initial content script under 10KB if possible. Users will not notice a 10KB script; they will absolutely notice a 500KB bundle running on every page load.
Common Pitfalls Reference#
These are the bugs that show up in every extension developer's first few projects. Each one is a time sink if you do not know what you are looking at.
Extension context invalidated: When your extension updates or reloads during development, existing content scripts become orphaned. Their chrome.runtime reference breaks, and any call to chrome.runtime.sendMessage throws. Wrap all chrome.* calls in try-catch and check chrome.runtime?.id before calling APIs.
Content Security Policy blocks: Some pages have strict CSPs that prevent inline script execution. If you are injecting <script> tags into the page (for MAIN world access), the page's CSP may block them. Use chrome.scripting.executeScript with world: "MAIN" instead — the browser grants this special CSP bypass for extensions.
Race conditions with page scripts: Your content script and the page's JavaScript both modify the same DOM. If you add a click handler to a button, the page might replace that button's parent container a moment later. Always re-query elements rather than caching stale references.
Z-index wars: Injecting a floating UI panel and discovering it appears behind the page's modal, dropdown, or sticky header. Using z-index: 2147483647 (the max 32-bit integer) is standard practice, but some sites also use this value. Shadow DOM combined with a fixed-position host element is the most reliable solution.
Memory leaks from undisconnected observers: A MutationObserver that is never disconnected holds a reference to its callback's closure and every variable captured by that closure. Over a long browsing session, this adds up. Always store observer references and disconnect them during cleanup.
Putting It All Together#
A production content script combines all of these patterns: defensive DOM queries, scoped observers, Shadow DOM for UI isolation, typed message passing, and lazy loading. The key architectural principle is to keep the content script as thin as possible. It should be an event-driven relay between the page DOM and your service worker, not a self-contained application.
Before publishing, run through this final check: does your content script degrade gracefully when elements are missing? Does it clean up observers and injected UI on SPA navigations? Does it handle the extension-context-invalidated error? Does it avoid layout thrashing and unnecessary DOM polling? If the answer to all of these is yes, you are building content scripts at a professional level.
For the broader context of how content scripts fit into the MV3 architecture, read Complete Guide to Manifest V3 in 2026. And when you are ready to ship, our Chrome extension launch checklist covers everything from store listing to submission.
Interactive tool
Chrome Extension Manifest Validator
Validate your manifest.json including content script declarations, permissions, and injection rules before submitting to the Chrome Web Store.
Open tool
Continue reading
Related articles
Debugging Chrome Extensions Like a Pro
Master Chrome extension debugging with DevTools. Debug service workers, content scripts, popups, storage, and network requests with practical techniques and code examples.
The Complete Guide to Manifest V3 in 2026
Everything you need to know about Manifest V3 in 2026: service workers, declarativeNetRequest, storage changes, and migration strategies for Chrome extension developers.
Chrome Alarms API and Scheduling Background Tasks
Master the Chrome Alarms API for scheduling background tasks. Covers periodic alarms, one-shot timers, service worker wake-up, sub-minute alternatives, and real-world patterns.