Registering Content Scripts at Runtime
Use chrome.scripting.registerContentScripts to add, update and remove persistent content scripts in MV3 — user-configured match patterns without a manifest change or store review.
Table of Contents
A content script declared in the manifest can only run where the manifest said, which means “let the user add their own sites” is impossible without shipping a new version. chrome.scripting.registerContentScripts solves this: it registers a script the same way the manifest does — automatically, at document start, on every matching navigation — but at runtime and under your control. This guide is part of scripting API dynamic injection.
How it differs from executeScript
executeScript injects once, into a tab you name, right now. registerContentScripts adds a standing registration that the browser applies to future navigations by itself. Only the second can run at document_start, and only the second survives worker eviction without your code being awake to inject.
Step-by-step
1. Declare the permission and the optional hosts
1{
2 "permissions": ["scripting", "storage"],
3 "optional_host_permissions": ["*://*/*"], // asked for per site, at runtime
4 "web_accessible_resources": [
5 { "resources": ["inject/reader.css"], "matches": ["<all_urls>"] }
6 ]
7}
Execution context: parsed at install. Requesting hosts as optional is what keeps the install prompt small and the store review short — the argument is laid out in requesting optional permissions at runtime.
2. Register a script for a user-chosen origin
1async function enableForOrigin(origin) {
2 const granted = await chrome.permissions.request({ origins: [`${origin}/*`] });
3 if (!granted) return false;
4
5 await chrome.scripting.registerContentScripts([{
6 id: `site-${btoa(origin).replace(/=/g, "")}`, // stable, derived from the origin
7 matches: [`${origin}/*`],
8 js: ["content/reader.js"],
9 css: ["inject/reader.css"],
10 runAt: "document_idle",
11 persistAcrossSessions: true,
12 allFrames: false,
13 }]);
14 return true;
15}
Execution context: the service worker, called from a user gesture in the popup — permissions.request is refused otherwise. Registration ids must be unique and stable: deriving one from the origin means re-running this call is a no-op rather than a duplicate.
3. Make registration idempotent
Registering an id that already exists rejects the whole call. Read first, then decide between register and update.
1async function upsert(script) {
2 const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [script.id] });
3 if (existing.length) {
4 await chrome.scripting.updateContentScripts([script]);
5 } else {
6 await chrome.scripting.registerContentScripts([script]);
7 }
8}
Execution context: the service worker. updateContentScripts merges the fields you pass, so omitting css leaves the previously registered stylesheet in place rather than clearing it.
4. Reconcile on startup
persistAcrossSessions: true means the browser keeps the registration, but an extension update clears it and a permission the user revoked leaves a registration that can never match. Reconcile both on every start.
1async function reconcile() {
2 const [{ enabledOrigins = [] }, registered, perms] = await Promise.all([
3 chrome.storage.sync.get("enabledOrigins"),
4 chrome.scripting.getRegisteredContentScripts(),
5 chrome.permissions.getAll(),
6 ]);
7
8 const allowed = new Set(perms.origins ?? []);
9 const wanted = enabledOrigins.filter((o) => allowed.has(`${o}/*`));
10
11 const staleIds = registered
12 .filter((r) => !wanted.some((o) => r.id === idFor(o)))
13 .map((r) => r.id);
14 if (staleIds.length) await chrome.scripting.unregisterContentScripts({ ids: staleIds });
15
16 for (const origin of wanted) await upsert(scriptFor(origin));
17}
18
19chrome.runtime.onStartup.addListener(reconcile);
20chrome.runtime.onInstalled.addListener(reconcile);
Execution context: the service worker, at the top level plus two lifecycle events. Keeping the user’s list in chrome.storage.sync and the registrations derived from it means a new device picks up the same sites once the user grants the permissions there.
5. Clean up when the user revokes access
1chrome.permissions.onRemoved.addListener(async ({ origins = [] }) => {
2 const ids = origins.map((o) => idFor(o.replace(/\/\*$/, "")));
3 await chrome.scripting.unregisterContentScripts({ ids }).catch(() => {});
4});
Execution context: the service worker. Unregistering an id that does not exist rejects, so the catch is doing real work here — the event can fire for origins you never registered.
Scaling to a user list of hundreds of sites
An extension that lets the user enable per-site behaviour will eventually meet a user with three hundred sites enabled. Registering three hundred scripts is legal and is the wrong shape: each registration is evaluated against every navigation, and the reconcile in step 4 becomes three hundred API calls on every startup.
Collapse them. One registration can carry many match patterns, so the natural unit is not “one per site” but “one per behaviour”:
1async function syncRegistrations(origins) {
2 const matches = origins.map((o) => `${o}/*`);
3 const script = {
4 id: "user-sites",
5 matches: matches.length ? matches : ["https://example.invalid/*"], // never empty
6 js: ["content/reader.js"],
7 css: ["inject/reader.css"],
8 runAt: "document_idle",
9 persistAcrossSessions: true,
10 };
11 const existing = await chrome.scripting.getRegisteredContentScripts({ ids: ["user-sites"] });
12 await (existing.length
13 ? chrome.scripting.updateContentScripts([script])
14 : chrome.scripting.registerContentScripts([script]));
15}
Execution context: the service worker. The placeholder pattern matters: matches may not be empty, and unregistering-then-registering on every change is both slower and briefly leaves the user unprotected. A pattern that matches nothing real is the cleanest way to express “enabled for zero sites”.
With one registration, the reconcile becomes a single diff. It also makes the permission story clearer — chrome.permissions.getAll() returns the granted origins, and the registration’s matches should simply be that list, filtered to what the user still wants:
1const granted = new Set((await chrome.permissions.getAll()).origins ?? []);
2const wanted = enabledOrigins.filter((o) => granted.has(`${o}/*`));
3await syncRegistrations(wanted);
Execution context: the service worker, on startup and whenever the user’s list or the granted permissions change. Deriving matches from the intersection means a permission revoked in the browser’s own UI takes effect on the next reconcile without any special handling.
The remaining cost is script size. One script matched against three hundred origins runs on all of them, so any per-site configuration has to be looked up at runtime rather than baked into the registration — a storage.local read keyed by origin, done once at document_idle, is the usual shape.
Cross-browser variation
- Chrome / Edge: available from Chrome 96, with
persistAcrossSessionsdefaulting totrue. Registrations are cleared by an extension update, so theonInstalledreconcile is required. - Firefox:
browser.scripting.registerContentScriptslanded in Firefox 101 and behaves the same. Firefox also has the oldercontentScripts.register, which returns a handle and does not persist — do not mix the two. - Safari: supported from Safari 16.4 but with less reliable persistence across browser restarts; run the reconcile step on
onStartupand treat persistence as an optimisation rather than a guarantee. - All three: registration does not affect tabs that are already open. Either reload the tab or follow up with an
executeScriptfor the current tab, as in injecting CSS and JS with executeScript.
Verification
- List what is actually registered from the service worker console:
1(await chrome.scripting.getRegisteredContentScripts())
2 .map((s) => ({ id: s.id, matches: s.matches, runAt: s.runAt }));
3// [{ id: "site-aHR0cHM6Ly9leGFtcGxlLmNvbQ", matches: ["https://example.com/*"], runAt: "document_idle" }]
Execution context: the service worker console. An empty array after an update means the reconcile did not run — check that onInstalled is registered synchronously.
- Open a matching site in a new tab and confirm the script runs; then confirm an already-open tab does not until reloaded.
- Revoke the host permission from
chrome://extensions → Details → Site accessand confirm the registration disappears. - Restart the browser and confirm the registration is still listed.
FAQ
Does a registered script count against any limit?
There is no documented cap on the number of registrations, but each one is evaluated against every navigation. Prefer a handful of registrations with several match patterns each over hundreds of single-pattern registrations.
Can I register a script for a host I do not have permission for?
No — the call rejects. Request the permission first and handle a refusal as a normal outcome rather than an error.
Is world: "MAIN" allowed here?
Yes on Chrome, where a registered script can run in the page’s own world. The isolation trade-offs are covered in bridging data between main world and isolated world.
Related
- Injecting CSS and JS with executeScript — the one-shot counterpart for the current tab.
- Injecting only after a user gesture with activeTab — the permission-light alternative.
- Declarative vs programmatic content script registration — choosing between the manifest and this API.
- Scripting API dynamic injection — the parent guide to injection in MV3.