← Back to blog
Tutorial · 10 min read

Content Scripts in Chrome Extensions: The Practical Guide

Content scripts run in the page — but in their own world. Here's how to read the DOM, inject UI, and talk to your service worker.

Illustration for the ManifestGo article: Content Scripts in Chrome Extensions: The Practical Guide

Content scripts are the part of a Chrome extension that runs inside a web page. They can read and modify the DOM — but they live in an isolated JavaScript world, separate from the page's own scripts.

What content scripts can and can't do

  • Can: read the DOM, modify it, inject UI elements, listen to events.
  • Can't: access the page's JavaScript variables directly.
  • Can't: use most `chrome.*` APIs — only `chrome.runtime` and `chrome.storage`.
  • Can: send messages to the service worker for anything else.

The message-passing pattern

When a content script needs something only the service worker can do — calling an external API, opening a new tab, reading `chrome.tabs` — it uses `chrome.runtime.sendMessage`. The service worker listens via `chrome.runtime.onMessage.addListener` and responds asynchronously.

Injecting UI cleanly

Use Shadow DOM for any UI you inject. It isolates your CSS from the host page so your overlay isn't broken by site-specific styles, and your styles don't leak into the page.

A minimal, robust pattern looks like this: create a host element, attach a shadow root in open mode, and render everything inside it. Because the host page can re-render at any time, append the host to document.documentElement rather than document.body, and re-check on a MutationObserver tick that your node is still attached.

Registering the script: manifest vs. programmatic

There are two ways to run a content script. Declarative registration in manifest.json under content_scripts fires automatically on matching URLs. Programmatic injection via chrome.scripting.executeScript runs on demand from the service worker and only needs the activeTab permission, which reviewers treat far more kindly than a broad host permission.

  • Declarative: predictable, runs before your popup exists, but needs host permissions up front.
  • Programmatic: minimal permissions, runs only after a user gesture, better for Web Store review.
  • run_at: 'document_idle' is the safe default; use 'document_start' only when you must beat the page's own scripts.
  • all_frames: true is needed for iframe-heavy sites (Gmail, Notion) and is a common cause of 'it works on some pages'.

Talking to the page's own JavaScript

Because of the isolated world, you cannot read window variables the page defined. If you genuinely need them, inject a <script> tag whose src points at a web_accessible_resource in your extension, then relay data back to the content script with window.postMessage and an origin check. Never eval strings — Manifest V3's content security policy blocks it and the Web Store rejects it.

Surviving single-page apps

YouTube, Twitter/X, and most modern apps never reload the document, so a content script that runs once and exits will look broken after the first navigation. Watch for URL changes with a MutationObserver on document.title or by listening for the service worker's chrome.tabs.onUpdated event, and re-run your setup idempotently.

Debugging checklist

  1. Open DevTools on the page and switch the console context dropdown to your extension — content script logs do not appear in the default context.
  2. Check chrome://extensions for a red 'Errors' badge; manifest match-pattern typos surface there.
  3. Confirm your match pattern includes the scheme (https://*.example.com/* — not example.com).
  4. If the script never fires on a subframe, set all_frames: true.
  5. If styles look wrong, you're leaking into the page — move the UI into a Shadow DOM.

Generate a working content script instead

Every rule above is encoded in ManifestGo's builder. Describe the page behaviour you want and it produces the manifest entry, the isolated-world content script, the Shadow DOM UI, and the service-worker message handlers as one coherent, installable Manifest V3 project.

Manifest fields you'll actually configure

  • matches — array of match patterns like 'https://*.example.com/*'; a bare domain without scheme or trailing '/*' silently matches nothing.
  • exclude_matches — carve out subdomains or paths (e.g. exclude '*://mail.example.com/*' while matching the rest of the domain).
  • css — inject stylesheet files declaratively alongside js, useful for hiding elements before the script runs to avoid a flash of unstyled content.
  • world: 'MAIN' — new in Chrome 111+, lets content_scripts run directly in the page's own JS context without a separate injected <script> tag, replacing the old postMessage relay for simple cases.

declarativeContent and page-conditional activation

chrome.declarativeContent.PageStateMatcher lets you show the action icon only on pages matching a CSS selector or URL pattern, without granting host_permissions to read page content — useful when you only need to react to page structure, not read its data.

Common console errors and their fixes

  • 'Refused to execute inline script because it violates the following Content Security Policy directive' — your injected <script> uses inline code; move it to a separate file listed in web_accessible_resources.
  • 'Cannot access contents of the page. Extension manifest must request permission to access the respective host' — the URL wasn't covered by host_permissions or activeTab wasn't invoked by a user gesture first.
  • 'A listener indicated an asynchronous response by returning true, but the message channel closed' — sendResponse was called after the channel already timed out; keep async work under the response window or use chrome.runtime.connect for long-lived channels.

Performance: avoiding jank on every page load

A content script injected with run_at: 'document_start' blocks the page's own parsing until it finishes executing synchronous code. Keep document_start scripts to a few lines (usually just a CSS injection to prevent flicker) and defer everything else to document_idle or a requestIdleCallback inside the script.

MutationObserver patterns that don't leak memory

Always call observer.disconnect() when your target element is removed, and scope observers to the smallest container element possible rather than document.body — observing the whole document on a high-traffic SPA like Gmail can measurably slow down typing and scrolling.

web_accessible_resources in Manifest V3

Unlike MV2's flat array of filenames, MV3 requires an object with resources and matches: { resources: ['injected.js'], matches: ['https://*.example.com/*'] }. Forgetting the matches key is the most common reason 'Denying load of chrome-extension://...' appears in the page console.

Frequently asked questions

Why is my Chrome content script not running?

The three most common causes are a match pattern missing the URL scheme, the page being a subframe while all_frames is false, and the script running before the target element exists. Check chrome://extensions for errors, widen run_at to document_idle, and wait for the element with a MutationObserver.

Can a content script access the page's JavaScript variables?

No. Content scripts run in an isolated world with a shared DOM but a separate JavaScript context. To read page variables you must inject a script file listed under web_accessible_resources and pass data back with window.postMessage.

Which chrome APIs can a content script use?

Only a subset — chrome.runtime, chrome.storage, chrome.i18n, and parts of chrome.dom. Anything else (tabs, scripting, alarms, downloads) must be requested from the background service worker via chrome.runtime.sendMessage.

Should I use manifest content_scripts or chrome.scripting.executeScript?

Use declarative content_scripts when the behaviour must apply automatically to every matching page. Use chrome.scripting.executeScript with activeTab when the behaviour is triggered by the user, because it requires far fewer permissions and passes Chrome Web Store review more easily.

How do I stop my injected UI from breaking on the host page?

Render inside a Shadow DOM attached to a host element you append to document.documentElement. That isolates your CSS in both directions and keeps the host page's stylesheets from rewriting your overlay.

What does world: 'MAIN' do for a content script in Manifest V3?

It runs the script directly inside the page's own JavaScript context instead of the isolated world, giving direct access to window-scoped variables the page defines, without needing the old inject-a-script-tag-and-postMessage workaround. It's available from Chrome 111 onward.

Why do I get 'Denying load of chrome-extension://...' in the console?

This happens when a resource is referenced by an injected script or stylesheet but wasn't declared under web_accessible_resources with a matches array covering the current page's origin.

Does document_start block page rendering?

Yes — a document_start content script runs before the DOM is parsed and executes synchronously, so heavy logic there will delay the page's first paint; keep document_start code minimal and move the rest to document_idle.

Keep reading