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.

 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.

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

MV3 Constraints box

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