Chrome Extension Content Script Guide (2026)
Chrome Extension Content Script Guide (2026)
A content script is the only part of a Chrome extension that can touch the page the user is actually looking at. Everything else — the service worker, the popup, the options page — runs in its own context with no access to the DOM in front of the user. If your extension reads page text, injects a button, highlights something, or reacts to what is on screen, a content script is doing it.
This chrome extension content script guide covers the Manifest V3 model as it stands in 2026: how to declare scripts, the match patterns and timing that cause most bugs, the isolated world and when to leave it, messaging with the service worker, dynamic injection, and how to debug the whole thing. It assumes you have loaded an unpacked extension before — if not, start with how to build a chrome extension and come back.
What a Content Script Is, and What It Is Not
A content script is a JavaScript file (optionally with CSS) that Chrome injects into a web page. Once running, it:
- Can read and modify the page DOM, listen to DOM events, and read computed styles.
- Can use
chrome.runtimefor messaging,chrome.storagefor persistence,chrome.i18nfor localization, and a small number of other APIs. - Cannot use most of the
chrome.*surface — nochrome.tabs, nochrome.cookies, nochrome.windows. Those live in the service worker. - Cannot see the page own JavaScript variables, libraries, or framework internals by default.
- Cannot make cross-origin requests that the host page could not make.
The mental model that avoids a lot of confusion: a content script is a guest in the page. It shares the furniture — the DOM — but not the memory.
Its counterpart is the service worker, which in Manifest V3 replaced the persistent background page. It holds the full API surface and no DOM, and Chrome shuts it down after a short idle period, restarting it when an event arrives. The division of labor in nearly every non-trivial extension: the content script sees the page, the service worker holds the privileges, and they talk by message.
Any chrome extension content script guide that skips that split produces developers who try to call chrome.tabs.query from a content script, get undefined, and lose an hour.
Declaring Content Scripts in the Manifest
The declarative path. Chrome injects the script automatically into every page matching your patterns, with no code required to trigger it.
{
"manifest_version": 3,
"name": "Example",
"version": "1.0",
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"exclude_matches": ["https://*.example.com/admin/*"],
"js": ["content.js"],
"css": ["content.css"],
"run_at": "document_idle",
"all_frames": false
}
]
}
The keys that matter:
matches — required. An array of match patterns describing where the script runs. This is your standing permission grant, and reviewers read it first.
exclude_matches — carve-outs from the above. Useful for skipping admin panels, checkout flows, or any page where injection could cause real damage.
js and css — the files to inject. CSS declared here is injected before the page renders regardless of run_at, which prevents a flash of unstyled injection.
run_at — timing, covered below. Defaults to document_idle.
all_frames — whether to inject into iframes as well as the top document. Defaults to false. Turning it on multiplies your execution count on ad-heavy pages, so turn it on only when you actually need frame content.
match_about_blank — injects into blank frames created by a matching page. Niche, but the fix when a script does not run inside a dynamically created iframe.
world — the default isolated world or MAIN. Covered below.
Match Patterns and Run Timing: Where the Bugs Live
If a chrome extension content script guide should have one warning label, it belongs here. Match patterns have a fixed shape: scheme, host, path. https://*.example.com/* matches any subdomain and any path over HTTPS. https://example.com/* matches only that exact host. The special pattern `<all_urls>` matches every URL the extension can access, and it is the grant most likely to slow a store review and scare users on the install prompt.
The three most common pattern mistakes:
- Forgetting the trailing path.
https://example.comis not a valid pattern. It needs the path segment:https://example.com/*. - Duplicating patterns to be safe.
https://*.example.com/*already covers bothwww.example.comand the bareexample.comin Chrome match-pattern semantics. Adding both is harmless, but duplicated entries across twocontent_scriptsblocks do inject the script twice. - Requesting
`<all_urls>`for a feature used on three sites. This is the most common over-permission in the store. List the sites.
Run timing has three values:
document_start— before the DOM is constructed. Nothing exists yet except the document object. Use this to inject CSS that must apply before first paint, or to install an event listener or API shim before page scripts run. Do not query for elements here.document_end— immediately after the DOM is complete, but before images and subframes finish loading. The right default for anything that reads or modifies static markup. Roughly equivalent toDOMContentLoaded.document_idle— the default. Chrome picks a moment betweendocument_endand the window load event, choosing the earlier of "DOM complete" and 200 milliseconds after window load. Fine for most work.
Here is the trap that no timing value solves: on a single-page application, none of these guarantee your content exists. A React or Vue app mounts after all three fire. If your script queries for an element and finds nothing, the fix is not an earlier run_at, it is a MutationObserver watching for the element to appear, or a small polling loop with a timeout.
function whenPresent(selector, callback, timeoutMs = 10000) {
const existing = document.querySelector(selector);
if (existing) return callback(existing);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
callback(el);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
setTimeout(() => observer.disconnect(), timeoutMs);
}
Always disconnect. An observer left watching subtree: true on a busy application is a real performance problem, and the most common reason users blame an extension for a slow site.
The Isolated World, and When to Escape It
Content scripts execute in an isolated world: a separate JavaScript context that shares the DOM with the page but nothing else. Practical consequences:
- The page cannot see or tamper with your variables, and you cannot accidentally collide with a page global.
- You can bundle any library version you like without conflicting with the page copy.
- You cannot read page JavaScript state. The Redux store, the framework instance, a global config object the page defined — all invisible.
- Event listeners you attach are yours; the page cannot remove them.
When you genuinely need page context — reading a global the site exposes, patching a page-level API, calling into a library the page loaded — there are two supported escapes.
Option one: world: "MAIN". Declare a second content script entry with "world": "MAIN" and it runs in the page context directly, with full access to page globals and no access to the chrome.* APIs at all. Communicate between your isolated script and your main-world script using window.postMessage or custom DOM events.
Option two: inject a script element. The older technique — create a script element pointing at a file bundled with your extension and append it to the document. The file must be listed under web_accessible_resources in the manifest, otherwise the page cannot load it.
Both routes carry the same security consequence, and it is worth being blunt: code running in the main world is exposed to the page, and the page is exposed to it. Never pass extension privileges, tokens, or user data through the main world. Treat anything arriving from it as untrusted input, and validate the origin on every postMessage handler.
Messaging Between Content Script and Service Worker
Because privileges live in the service worker and the DOM lives in the content script, messaging is the backbone of any real extension — and the part of a chrome extension content script guide that developers reread most.
One-shot messages. The common case.
// content.js
await chrome.runtime.sendMessage({
type: 'SAVE_SELECTION',
text: window.getSelection().toString(),
});
// service-worker.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SAVE_SELECTION') {
chrome.storage.local.set({ lastSelection: message.text })
.then(() => sendResponse({ ok: true }));
return true; // keep the channel open for the async response
}
});
That return true is the single most-missed line in Manifest V3 development. Without it the channel closes the moment the listener returns and your asynchronous sendResponse never arrives — a closed-port rejection that looks like a bug in your storage call.
Service worker to content script. The direction reverses the API — you need a tab ID, so it goes through chrome.tabs.sendMessage(tabId, message) from the worker side.
Long-lived ports. For streams rather than one-shots, chrome.runtime.connect opens a named port. Overkill for a single request.
The CORS rule. Content scripts are subject to the same cross-origin restrictions as the host page. A fetch to your own API from a content script will be blocked unless that API sends permissive CORS headers. The intended pattern is to message the service worker and make the request there, where extension host permissions apply instead of page CORS. This surprises nearly everyone once.
Service worker lifetime. The worker sleeps when idle and wakes on events. Never hold state in a module-level variable and expect it to survive — use chrome.storage for anything that must persist between events. A long-running operation started in the worker can also be cut short, so keep worker tasks small and idempotent.
Dynamic Injection with chrome.scripting
The alternative to declaring scripts in the manifest: inject them from the service worker, only when needed.
chrome.action.onClicked.addListener(async (tab) => {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (label) => {
document.title = label + ' ' + document.title;
},
args: ['[tagged]'],
});
});
func runs a function you define inline — note that it is serialized and re-parsed in the page, so it cannot close over variables from the worker. Anything it needs comes through args. Alternatively, files: ['inject.js'] injects a bundled file.
This requires the scripting permission plus access to the target tab, which is where the real win sits. With the activeTab permission, the extension gets temporary access to the current tab only when the user invokes it — clicks the toolbar icon, or triggers a keyboard command. No standing host permissions. No install prompt warning about reading data on all websites.
There is also chrome.scripting.registerContentScripts for registering match-based scripts at runtime, plus insertCSS and removeCSS for stylesheets.
The decision rule that this chrome extension content script guide comes down to: if the behavior must happen automatically on page load, declare it. If it happens in response to a user action, inject it on demand and take activeTab instead of host permissions. Most extensions declare when they should inject, and pay for it in review friction and user distrust.
A concrete example of the second pattern: a URL copier does not need a content script on any page. It listens for a keyboard command in the service worker, reads the active tab URL, and writes to the clipboard. The Ctrl+Shift+C extension is built this way — clipboard permission only, no declared content scripts, no network calls, no data collection. The whole feature is a command listener and a few lines of injection at the moment of use.
Injecting UI Without Breaking the Page
The section every chrome extension content script guide underestimates. If your script renders anything visible, page CSS will fight you — the site has global selectors, resets, !important rules, and a z-index war already in progress.
Use a shadow root. Attach one to a container element and render inside it. Page styles do not cross the boundary, and neither do yours.
const host = document.createElement('div');
host.style.cssText = 'position:fixed;top:16px;right:16px;z-index:2147483647';
document.body.appendChild(host);
const root = host.attachShadow({ mode: 'closed' });
root.innerHTML = '<style>p { font: 13px sans-serif; }</style><p>Hello</p>';
Practical notes:
mode: 'closed'stops the page reaching in throughelement.shadowRoot. Not a security boundary, but it prevents accidental collisions.- The maximum z-index is 2147483647. Use it and accept that some sites still stack above you.
- Assets need
web_accessible_resources. Images, fonts, and icons from your package must be listed there and referenced viachrome.runtime.getURL. - Clean up on navigation. On single-page applications your injected UI can survive a route change and land on the wrong screen.
- Never inject into every page by default. A floating widget on all sites is the fastest route to a one-star review.
Debugging, Permissions, and the Least-Privilege Path
Debugging. Open DevTools on the page. In the Console, use the context dropdown that reads "top" and switch it to your extension name — now console.log from your content script appears and you can evaluate against its isolated scope. In the Sources panel, content scripts appear under their own pane where you can set breakpoints. Errors thrown in the service worker do not appear here at all; open chrome://extensions, find your extension, and click the service worker link to get a dedicated inspector. More DevTools technique in chrome devtools tips and tricks.
Triage when a script does not run:
- Is the URL blocked — a
chrome://page, the Chrome Web Store, another extension page? Nothing will help. - Does the match pattern actually match, trailing
/*included? - Did you reload? Code changes need a tab reload; manifest changes need an extension reload.
- Is the element created later than your
run_at? Add the observer. - Is the content inside an iframe with
all_framesoff?
Permissions, honestly. Every declared content script is a permanent grant on every matching site. Users see it on the install prompt as reading and changing data on those sites, reviewers weigh it, and enterprise admins block on it. The least-privilege ladder, best first:
activeTabpluschrome.scripting, triggered by user action.- Declared scripts on a short, specific list of hosts.
optional_host_permissionsrequested at runtime when the user enables a feature.`<all_urls>`— only when the extension genuinely operates everywhere.
The performance dimension is the same argument in different clothes. Every matching content script is parsed and executed on every page load, before the user does anything. Ten extensions each injecting a broad script is a measurable cost per navigation — which is why the extension list is the first thing to check on a slow browser. For the wider tooling picture, see chrome extensions for web developers 2026.
Frequently Asked Questions
What is a content script in a Chrome extension? A JavaScript file that Chrome injects into a web page so it runs in the context of that page. It shares the page DOM but not the page JavaScript scope, and it is the only part of an extension that can read or modify what the user is currently looking at.
What is the difference between a content script and a service worker? The content script has the DOM but only a small slice of the extension APIs — mainly runtime, storage, and i18n. The service worker has the full API surface and no DOM, and Chrome shuts it down when idle. Most extensions need both and pass messages between them.
What is the isolated world? The separate JavaScript context content scripts run in. Both the page and your script see the same DOM, so both can read and change elements, but variables, libraries, and framework state defined by the page are completely invisible to the content script and vice versa.
When should I use declarative content scripts versus chrome.scripting? Declare them in the manifest when the script must run automatically on every matching page. Use chrome.scripting.executeScript from the service worker when the work only happens after a user action, because that path can rely on activeTab and requires no standing host permissions.
Why does fetch fail inside a content script? Content scripts are subject to the same cross-origin rules as the host page, so a request the page could not make is blocked for the script too. Send a message to the service worker and perform the fetch there, where the extension host permissions apply instead.
Why does my content script not run on some pages? Chrome blocks extension scripts on chrome:// pages, the Chrome Web Store, and other extension pages, and no permission changes that. Everywhere else, the usual causes are a match pattern missing its trailing path wildcard, content that mounts after your run timing, or an iframe with all_frames disabled.
How do I debug a content script? Open DevTools on the page and switch the console context dropdown from top to your extension, which gives you the isolated scope. The Sources panel lists content scripts in their own pane for breakpoints. Service worker errors need the separate inspector linked from chrome://extensions.
Inject Less, Ship Faster
The short version of this chrome extension content script guide: declare narrowly, run late enough that the DOM exists, treat the isolated world as a feature rather than an obstacle, keep privileges in the service worker and DOM work in the content script, and reach for dynamic injection with activeTab whenever the behavior is user-triggered. Extensions that follow that shape review faster, load faster, and survive Chrome platform changes with fewer rewrites. If you want to see a minimal version of the pattern in production, Ctrl+Shift+C copies the current tab URL in one keystroke with clipboard permission only, no declared content scripts, no network calls, and zero data collection. Install it, then go write one of your own.
Try Ctrl+Shift+C
Copy any URL with one keyboard shortcut. Free forever, no data collected.