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.
Prerequisites checklist
Before calling any chrome.scripting method, confirm:
scriptingpermission is declared inmanifest.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 —
executeScriptinto achrome://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".
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. scriptingpermission is mandatory even whenactiveTabis the only host grant — both must be declared.activeTabscope: 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. Usefunc(serialised function) orfiles(bundled file). - Serialisation boundary:
argsare JSON-serialised. NoMap,Set,Date, class instances, or functions inside args. allFrames: trueskips opaque origins: Frames loaded withsandboxattribute or fromblob:URLs are silently skipped.- Service worker eviction:
registerContentScriptsregistrations survive eviction; one-shotexecuteScriptcalls 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.
Related
- Injecting only after a user gesture with activeTab — the minimal permission model for injection.
- Bridging data between MAIN world and isolated world — a correlated, validated event channel.
- Injecting CSS and JS with executeScript — deep dive on args, return values, frame targeting, and CSS cleanup.
- Content Scripts & DOM Injection — declarative injection via manifest
content_scriptsand isolation patterns. - Best practices for content script isolation in MV3 — prevent world collisions and prototype pollution.
- Tabs API & Window Management — querying the right tab ID before injecting.
- Up to the Core APIs & Cross-Browser Data Management overview.