Declarative vs Programmatic Content Script Registration
Choose between manifest content_scripts, runtime registration and one-shot injection in MV3 — timing, permissions, store review and what each one costs on a busy page.
Table of Contents
There are three ways to get a content script onto a page and they are not interchangeable. The manifest declaration runs earliest and is fixed at publish time; runtime registration runs just as early but can change without a store review; one-shot injection runs late and only where the user just acted. Picking wrongly produces either an extension that cannot adapt or one that asks for far more access than it needs. This guide is part of content scripts and DOM injection.
The three mechanisms side by side
Step-by-step: picking and wiring each one
1. Declare in the manifest only what the product always does
If the extension’s core promise is “it works on GitHub”, then GitHub belongs in the manifest. Everything conditional belongs elsewhere.
1{
2 "content_scripts": [{
3 "matches": ["https://github.com/*"],
4 "js": ["content/core.js"],
5 "css": ["content/core.css"],
6 "run_at": "document_idle",
7 "all_frames": false
8 }],
9 "permissions": ["scripting", "storage"],
10 "optional_host_permissions": ["*://*/*"]
11}
Execution context: parsed at install. Every pattern in matches becomes a line in the install prompt, and a *://*/* here is the single biggest cause of both install abandonment and extended review — the argument made in writing a permission justification that passes.
2. Register at runtime for anything the user configures
1async function enableSite(origin) {
2 const granted = await chrome.permissions.request({ origins: [`${origin}/*`] });
3 if (!granted) return false;
4 await syncRegistrations(); // one registration carrying every enabled origin
5 return true;
6}
Execution context: the service worker, driven from a click in the popup or options page. The mechanics — idempotent upserts, reconciling against granted permissions, collapsing many origins into one registration — are covered in registering content scripts at runtime.
3. Inject once for a deliberate, one-off action
1chrome.action.onClicked.addListener(async (tab) => {
2 await chrome.scripting.executeScript({
3 target: { tabId: tab.id },
4 files: ["content/summarise.js"],
5 });
6});
Execution context: the service worker, inside the click handler where the activeTab grant is live. This needs no host permission at all, which makes it the cheapest possible way to offer “do this thing on the current page” — and the only one that cannot run at document_start.
4. Combine them without double-running
The failure mode of mixing mechanisms is a script that runs twice: once from a registration and once from a click. Give every content script an idempotence guard as its first statement.
1// content/core.js
2if (window.__extCoreLoaded) {
3 // Already running in this document — a second injection is a no-op.
4} else {
5 window.__extCoreLoaded = true;
6 start();
7}
Execution context: the content script’s isolated world. The isolated world has its own window, so this flag is invisible to the page and to other extensions — but it is shared between all of your injections into that document, which is exactly the scope needed.
5. Decide run_at deliberately
document_start runs before the page’s own scripts and before the DOM exists; document_end after parsing; document_idle when the browser judges the page settled. Choosing document_start for a script that needs the DOM produces a script that runs and finds nothing.
1{
2 "matches": ["https://example.com/*"],
3 "js": ["content/early.js"],
4 "run_at": "document_start" // to override a global before the page reads it
5}
Execution context: parsed at install. At document_start the only safe operations are ones that do not need document.body — installing a MutationObserver on document.documentElement, defining a property the page will later read, or injecting a stylesheet. The full timing model is in content script run_at timing explained.
The cost on the page
A content script is not free. It is parsed and executed in the page’s renderer process on every matching navigation, and a broad match pattern means every navigation. On a browser with twenty tabs, a script matching *://*/* is twenty parses and twenty executions, plus its memory, before it has done anything useful.
That cost is why narrowing matters even when the permission is granted. A registration that matches only the origins the user enabled does the same work on three sites instead of every site, and the difference is measurable in the page-load numbers described in measuring content script impact on page load.
Two cheap habits keep it small. First, keep the always-injected script tiny and load the rest on demand:
1// content/core.js — a few kilobytes, no dependencies
2if (!document.querySelector("[data-ext-target]")) return; // nothing to do here
3const { run } = await import(chrome.runtime.getURL("content/heavy.js"));
4run();
Execution context: the content script’s isolated world. Dynamic import() of an extension URL works in a content script provided the file is listed in web_accessible_resources — unlike in the service worker, where a dynamic import on the registration path is the bug described in messages sent while the worker is starting.
Second, bail early and loudly. A script that returns in the first five lines on 95% of pages costs almost nothing; one that builds a MutationObserver over the whole document before checking whether it is needed costs on every page.
Moving from a broad manifest match to a narrow one
Extensions that shipped with *://*/* in the manifest and want to narrow it face a real migration problem: existing users already granted that access, and the narrowing must not turn their working install into a broken one.
The sequence that works is additive, then subtractive, across two releases.
In the first release, keep the broad manifest match and add the runtime registration path alongside it. On onInstalled, record which origins the user has actually been using — derived from your own usage data or, more simply, from the sites where the feature has been enabled.
1chrome.runtime.onInstalled.addListener(async ({ reason }) => {
2 if (reason !== "update") return;
3 const { enabledOrigins } = await chrome.storage.sync.get("enabledOrigins");
4 if (enabledOrigins) return; // already migrated
5 const seen = await deriveUsedOrigins(); // from your own records
6 await chrome.storage.sync.set({ enabledOrigins: seen });
7});
Execution context: the service worker, on update only. Seeding the list from observed usage means the second release does not present the user with an empty configuration screen.
In the second release, remove the broad match from the manifest and register from the stored list. Chrome keeps previously granted host permissions when a manifest narrows, so the registration succeeds without any new prompt — the user notices nothing except a shorter permission list on the extension’s details page.
The reverse direction is much harsher. Adding a host match to the manifest disables the extension for every existing user until they re-accept the new permissions, which typically costs a double-digit percentage of the install base. That asymmetry is the strongest practical argument for declaring narrowly at launch and reaching for optional host permissions when the product grows.
Cross-browser variation
- Chrome / Edge: all three mechanisms are available.
activeTabcoversexecuteScripton the current tab after a gesture; registrations need real host permissions. - Firefox: supports the manifest declaration and
browser.scripting.registerContentScriptsfrom Firefox 101. Firefox also retains the oldercontentScripts.register, which returns a handle and does not persist — do not mix it with the scripting API’s registrations. - Safari: manifest declarations are the most reliable path. Runtime registration works from Safari 16.4 but persistence across restarts is less dependable, so reconcile on
onStartuprather than trustingpersistAcrossSessions. - All three: none of the mechanisms can inject into a restricted page, and none of them retroactively affect tabs that are already open — a fresh registration reaches the next navigation, not the current document.
Verification
- Confirm what is registered, and that it matches what you meant to register:
1({
2 registered: (await chrome.scripting.getRegisteredContentScripts()).map((s) => s.id),
3 granted: (await chrome.permissions.getAll()).origins,
4});
5// { registered: ["user-sites"], granted: ["https://github.com/*", "https://gitlab.com/*"] }
Execution context: the service worker console. A registration whose matches are not a subset of granted will never fire — the browser silently declines rather than rejecting the registration.
- Open a matching page and confirm the script ran exactly once by logging the idempotence guard.
- Click the action on a non-matching page and confirm the one-shot injection works with no host permission.
- Reload the extension and confirm registrations survive; install an update and confirm the reconcile restores them.
FAQ
Can I have the same file in the manifest and in a registration?
Yes, and the idempotence guard is what makes it safe. It is a legitimate pattern: ship one guaranteed site in the manifest and let the user add more at runtime, with a single script serving both.
Does a manifest content script run before the page’s own inline scripts?
At document_start, yes — that is the only reliable way to intercept something the page does immediately. At document_end or document_idle, no.
Why does my registration not appear after an update?
Because an extension update clears runtime registrations. Reconcile from chrome.runtime.onInstalled, which fires for updates as well as first installs.
Related
- Registering content scripts at runtime — the mechanics of the middle option.
- Content script run_at timing explained — choosing the injection moment.
- Injecting only after a user gesture with activeTab — the permission-free path.
- Content scripts and DOM injection — the parent guide.