Scripting API & Dynamic Injection

Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.

The biggest source of confusion when porting a Manifest V2 extension is the removal of tabs.executeScript. In MV3 that capability moved to chrome.scripting, a dedicated API that separates dynamic injection from the tabs-lifecycle domain. Get the mental model wrong — declaring the wrong world, requesting host permissions when activeTab is enough, or calling executeScript before the tab’s document is interactive — and you will face silent failures that only appear on specific pages. This guide is part of Core APIs & Cross-Browser Data Management.

The key shift: chrome.scripting functions take structured injection targets, optional args arrays that are serialised across the process boundary, and an explicit world choice. The callback style is gone; every call returns a Promise. Start by designing the permission model, then choose whether you need a one-shot executeScript or a persistent registerContentScripts that survives service worker restarts.

chrome.scripting injection flow in Manifest V3The service worker calls chrome.scripting.executeScript or registerContentScripts; the browser injects the function or file into the target tab, running in either the ISOLATED or MAIN world.Service Workerchrome.scripting callBrowser Enginevalidates permissionsresolves target frameISOLATED worldchrome.* APIs availableMAIN worldpage JS accessiblePermissionsactiveTab or host perms

Prerequisites checklist

Before calling any chrome.scripting method, confirm:

  • scripting permission is declared in manifest.json — missing this causes a runtime error on every call.
  • A permission strategy is chosen: activeTab (user-gesture gated, no install warning) or an explicit host permission pattern (*://*/* or specific origins).
  • The target tab is in a state where the document exists — executeScript into a chrome:// URL or a tab that has not finished navigating will fail silently or throw.
  • For registerContentScripts, understand that registrations persist across service worker restarts and accumulate unless explicitly removed.

1. Declare the scripting permission

The scripting permission is required regardless of whether you also declare host permissions or rely on activeTab.

1{
2  "manifest_version": 3,
3  "name": "Injector Demo",
4  "permissions": ["scripting", "activeTab"],
5  // Use host_permissions for background injection without user gesture:
6  "host_permissions": ["https://example.com/*"]
7}

Execution context: Parsed by the extension host at install time. The scripting key is MV3-only — it does not exist in MV2. Firefox requires the same key from Manifest V3 onward; Safari requires it and enforces it strictly.

2. One-shot injection with executeScript

chrome.scripting.executeScript injects a function, an arguments array, or a list of files into a specific tab frame. The gotcha: the func property must be a serialisable function — closures that capture outer variables are NOT transferred. Pass runtime data through args.

What crosses the process boundary during an executeScript callThe function is serialised as source text, sent to the target renderer, executed there, and only its structured-clonable return value comes back.Service workerBrowser processTarget rendererexecuteScript({ target, func, args })permission + host access checkfunction source + cloned argsclosures are lost hererun in the isol…InjectionResult[] per framestructured-clonable values only
The function is shipped as text — nothing from the worker's scope travels with it.
 1// background service worker — registered at top level
 2chrome.action.onClicked.addListener(async (tab) => {
 3  if (!tab.id) return;
 4
 5  const [result] = await chrome.scripting.executeScript({
 6    target: { tabId: tab.id },
 7    func: highlightKeyword,
 8    args: ["chrome.scripting"],          // serialised across process boundary
 9    world: "MAIN",                       // needs access to page's window object
10  });
11
12  console.log("Injection returned:", result.result);
13});
14
15function highlightKeyword(keyword: string): number {
16  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
17  let count = 0;
18  let node: Text | null;
19  while ((node = walker.nextNode() as Text)) {
20    if (node.textContent?.includes(keyword)) count++;
21  }
22  return count;
23}

Execution context: chrome.action.onClicked fires in the service worker; the func body runs in the target tab’s renderer process. chrome.* APIs are unavailable in world: "MAIN" — use world: "ISOLATED" (the default) if you need extension APIs inside the injected code. Firefox supports the same call signature from MV3; Safari supports executeScript but world: "MAIN" required a Safari 17+ update.

3. Injecting files

Injecting a pre-bundled file avoids the closure-serialisation pitfall and is required when the injected code imports other modules or is too large to inline.

1await chrome.scripting.executeScript({
2  target: { tabId: tab.id, allFrames: true },
3  files: ["content/reader.js"],
4  world: "ISOLATED",
5});

Execution context: Runs in the service worker; reader.js is evaluated in the renderer process of every frame in the target tab when allFrames: true. File paths are relative to the extension root. Firefox treats allFrames identically; Safari supports it but iframes from opaque origins are silently skipped.

For detailed patterns around file injection, passing arguments back, and cleaning up with removeCSS, see injecting CSS and JS with executeScript.

4. CSS injection with insertCSS and removeCSS

CSS injection shares the same permission model as script injection but uses insertCSS / removeCSS. The critical constraint: CSS injected this way is scoped to the specific tab navigation — it does not persist across navigations.

 1async function applyDarkOverlay(tabId: number): Promise<void> {
 2  await chrome.scripting.insertCSS({
 3    target: { tabId },
 4    css: `body { filter: invert(0.9) hue-rotate(180deg); }`,
 5  });
 6}
 7
 8async function removeDarkOverlay(tabId: number): Promise<void> {
 9  await chrome.scripting.removeCSS({
10    target: { tabId },
11    css: `body { filter: invert(0.9) hue-rotate(180deg); }`,
12  });
13}

Execution context: Both calls execute in the service worker; the CSS is applied or removed in the renderer. The css string must be identical between insertCSS and removeCSS or the remove call silently no-ops. Firefox supports both; Safari supports insertCSS but removeCSS was only added in Safari 16.4.

5. Persistent registration with registerContentScripts

chrome.scripting.executeScript is fire-and-forget for the current tab. When you need scripts that inject automatically into every matching URL — and that survive service worker restarts — use registerContentScripts. The registration is stored by the browser, not in your code’s memory.

 1// Register once — idempotent guard required to avoid duplicate-ID errors
 2chrome.runtime.onInstalled.addListener(async () => {
 3  const existing = await chrome.scripting.getRegisteredContentScripts();
 4  const ids = new Set(existing.map((s) => s.id));
 5
 6  if (!ids.has("auto-reader")) {
 7    await chrome.scripting.registerContentScripts([{
 8      id: "auto-reader",
 9      matches: ["https://news.ycombinator.com/*", "https://lobste.rs/*"],
10      js: ["content/reader.js"],
11      css: ["content/reader.css"],
12      runAt: "document_idle",
13      world: "ISOLATED",
14    }]);
15  }
16});

Execution context: onInstalled fires in the service worker on extension install or update. The registration persists even after the worker is evicted. Call chrome.scripting.unregisterContentScripts to remove by ID during updates. Firefox supports registerContentScripts from v112+; Safari from v17.

To update a registration, call chrome.scripting.updateContentScripts with just the changed fields — you do not need to unregister and re-register. Unregistering and registering on every install is safe but wasteful if dozens of scripts are involved.

6. MAIN vs ISOLATED world

Choosing the wrong world is the most common chrome.scripting bug. The default ISOLATED world runs in a separate JavaScript environment with access to chrome.* APIs but no access to page-defined globals. MAIN runs in the page’s own JavaScript context — able to call functions defined by the page, read page globals, and patch page APIs — but loses all chrome.* access.

What each injection world can reachThe MAIN world sees the page's own JavaScript but no extension APIs; the isolated world sees the extension APIs but not the page's variables. Both share the DOM.MAIN worldpage globals, page libraries, no chrome.*the page can tamper with itShared DOMthe one surface both worlds seeuse CustomEvent to bridgeISOLATED worldchrome.runtime, chrome.storage, own heapdefault for content scriptsService workerissues the injection, holds permissionsnever touches the DOM
The DOM is the only thing both worlds share — everything else is deliberately partitioned.
 1// ISOLATED — read extension config, post a message to the page
 2await chrome.scripting.executeScript({
 3  target: { tabId },
 4  world: "ISOLATED",
 5  func: () => {
 6    // chrome.storage is available here
 7    window.postMessage({ type: "EXT_READY" }, "*");
 8  },
 9});
10
11// MAIN — patch a page API the page already defined
12await chrome.scripting.executeScript({
13  target: { tabId },
14  world: "MAIN",
15  func: (patchValue: string) => {
16    // window.somePageGlobal is accessible here
17    (window as any).somePageGlobal = patchValue;
18  },
19  args: ["patched"],
20});

Execution context: Both execute in the renderer, in different V8 contexts. chrome.* APIs are only available in ISOLATED. A common pattern is to combine both: inject an ISOLATED script that reads storage, then inject a MAIN script that consumes the result passed via a DOM CustomEvent or window.postMessage. Firefox supports both worlds from MV3 with the same semantics; Safari requires Safari 17+ for world: "MAIN".

7. Choosing an injection strategy

The scripting API offers several ways to get code into a page, and the choice has consequences for permissions, timing, performance and review. Thinking of it as a small decision tree avoids the common default of registering a content script for every URL and filtering at runtime.

The first question is whether the code must run without the user doing anything. If it only runs when the user asks — a toolbar click, a context-menu item, a keyboard command — then executeScript under the activeTab grant needs no host permission at all, adds nothing to the install prompt, and costs nothing on pages where the user never invokes it. That is the right answer far more often than extensions assume, and it is covered in injecting only after a user gesture with activeTab.

If the code must run automatically, the second question is whether the set of sites is fixed by the product or chosen by the user. A fixed set belongs in the manifest; a user-chosen set belongs in a runtime registration built from optional host permissions, so the install prompt stays small and the user grants access one site at a time, as in registering content scripts at runtime.

The third question is which world the code needs. Almost everything belongs in the isolated world, which protects the extension’s globals and gives access to chrome.runtime. Only code that must read or patch the page’s own JavaScript — a framework’s state, a history API hook — needs the main world, and that code should be as small as possible, communicating with an isolated-world counterpart through a narrow, validated channel.

8. Timing, frames and repeat injection

Three practical details decide whether an injected feature behaves reliably. Timing: a one-shot injection runs when you call it, which is after the page has loaded; anything that must precede the page’s own scripts needs a registration or manifest entry with document_start. Frames: allFrames reaches only frames present at the moment of injection and only those you have access to; late-created frames and about:blank frames need a registration with matchOriginAsFallback. Repetition: the same script can arrive twice — from a registration and a click, or from two clicks — so every injected script should begin with a guard that makes a second run a no-op.

1if (!window.__readerInjected) {
2  window.__readerInjected = true;
3  start();
4}

Execution context: the isolated world of the target frame. The flag lives on the isolated world’s own window, invisible to the page and shared by every injection from your extension into that document — exactly the scope a repeat guard needs.

9. Keeping injected code cheap

An injected script is parsed and run in someone else’s page, often on every navigation, and its cost is borne by the site’s performance, not yours. Keep the injected part small, decide early whether there is anything to do, and load heavier code only when needed from a web-accessible module. Return plain data from injected functions rather than DOM references, which cannot cross back to the worker, and pass inputs as arguments rather than relying on closures, which do not survive serialisation — the rules set out in passing arguments to injected functions.

10. Explaining injection to reviewers and users

Injection is the capability store reviewers look at most closely, because it is the one that lets an extension read and change the pages people visit. An extension that injects narrowly and can say why has a much easier review than one that injects everywhere and filters at runtime, even when the second one is doing nothing harmful.

Three habits make the explanation easy. Keep match patterns as narrow as the feature allows, and prefer optional host permissions granted per site to broad patterns granted at install. Keep the injected code readable — minified is fine, obfuscated is not — so a reviewer can see what it does. And write down, in the permission justification and the store listing, exactly which pages the extension touches and what it does there, in words a non-developer would understand. The same honesty helps users: an extension that says “runs only on the sites you enable, to add a reading view” is one people are comfortable installing, and the per-site permission prompt then confirms the promise instead of contradicting it.

When the extension genuinely needs broad access — an ad blocker, an accessibility tool that must work everywhere — say so plainly and explain the reason. Reviewers approve broad access regularly; what they reject is broad access that the listing does not explain, as discussed in writing a permission justification that passes.

MV3 constraints to design around

  • No tabs.executeScript: That MV2 API is removed. Any call to it in MV3 throws at runtime.
  • scripting permission is mandatory even when activeTab is the only host grant — both must be declared.
  • activeTab scope: Grants temporary permission to the exact tab the user clicked. It expires on navigation, tab close, or extension reload. You cannot inject into iframes on other origins unless you also have a matching host permission.
  • No eval-equivalent: You cannot pass a raw string of JavaScript as code. Use func (serialised function) or files (bundled file).
  • Serialisation boundary: args are JSON-serialised. No Map, Set, Date, class instances, or functions inside args.
  • allFrames: true skips opaque origins: Frames loaded with sandbox attribute or from blob: URLs are silently skipped.
  • Service worker eviction: registerContentScripts registrations survive eviction; one-shot executeScript calls do not — they must be re-triggered by user interaction or a persistent alarm.

Cross-browser notes

Chrome/Edge (baseline): Full chrome.scripting API including registerContentScripts, world: "MAIN", and removeCSS. activeTab is the recommended minimal permission for user-gesture-triggered injection.

Firefox: Uses browser.scripting with native Promises. registerContentScripts is supported from Firefox 112. world: "MAIN" is supported from Firefox 102 but was initially behind a flag — test against your minimum Firefox target. Firefox also still supports MV2 tabs.executeScript in its MV2-compatible mode, so be careful not to mix APIs when targeting both manifest versions.

Safari: chrome.scripting is supported via WebKit’s browser.* namespace and works with the webextension-polyfill. world: "MAIN" requires Safari 17.0+. removeCSS requires Safari 16.4+. Safari enforces stricter content security policy on injected scripts and will reject inline event handlers created by injected JS. Safari does not support registerContentScripts prior to Safari 17.

For a full namespace and promise-support table, see the cross-browser API compatibility reference.

Two further pages cover the decisions this guide only introduces. Injecting only after a user gesture with activeTab covers shipping without a broad host permission: what the grant covers, when it lapses, and how to recover. Bridging data between MAIN world and isolated world builds the narrow, validated channel that lets an injected page script hand data to your extension without opening a door back the other way.

Further guides in this topic

The guides below go deeper into specific scripting api and dynamic injection problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • Injecting into iframes and All Frames — Target sub-frames with chrome.scripting — allFrames, frameIds, matchOriginAsFallback for about:blank and data: frames, and how to talk to a specific frame.
  • Passing Arguments to Injected Functions — Get data into and out of chrome.scripting.executeScript — the args array, structured-clone limits, why closures do not survive injection, and reading results back.
  • 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.