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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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.

Three ways to get code into a pageManifest content_scripts, scripting.registerContentScripts and scripting.executeScript compared on timing, persistence, whether a store review is needed, and how they are removed.Propertymanifest content_scriptsregisterContentScriptsexecuteScriptCan run at document_startYesYesEffectively noApplies to future navigationsYesYesNoChangeable without a reviewNoYesYesSurvives worker evictionYesYesN/ANeeds a host permissionDeclared upfrontGranted for the matchactiveTab is enoughRemoved byAn updateunregisterContentScriptsNothing — it already ran
Runtime registration is the only option that is both early and changeable.

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.

Adding a site from the popupThe user adds a site, the popup asks the worker, the worker requests the host permission, registers the script and records the origin so it can be reconciled later.PopupService workerpermissions APIscripting APIenableForOrigin(origin)from a clickpermissions.request({origins})granted: trueregisterContentScripts([…])storage.sync: enabledOrigins += originok — reload the tab to apply
The permission request must originate from the gesture — which is why the popup, not the worker, starts the sequence.

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.

Reconcile cost by registration strategyAPI calls needed to reconcile registrations on startup for 300 enabled sites, comparing one registration per site against a single collapsed registration.One registration per site301 API calls1 list + 300 upsertsBatched in groups of 507 API callsOne registration, many matches2 API calls1 list + 1 update
The collapsed strategy is one read and at most one write, regardless of how many sites the user has enabled.

Cross-browser variation

  • Chrome / Edge: available from Chrome 96, with persistAcrossSessions defaulting to true. Registrations are cleared by an extension update, so the onInstalled reconcile is required.
  • Firefox: browser.scripting.registerContentScripts landed in Firefox 101 and behaves the same. Firefox also has the older contentScripts.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 onStartup and 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 executeScript for the current tab, as in injecting CSS and JS with executeScript.

Verification

  1. 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.

  1. Open a matching site in a new tab and confirm the script runs; then confirm an already-open tab does not until reloaded.
  2. Revoke the host permission from chrome://extensions → Details → Site access and confirm the registration disappears.
  3. 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.

Other Core APIs & Cross-Browser Data Management Resources