Replacing tabs.executeScript with chrome.scripting

Migrate tabs.executeScript, insertCSS and removeCSS to chrome.scripting in MV3: target objects, func and args instead of code strings, frame targeting, return values, worlds and error handling.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

chrome.tabs.executeScript is gone in MV3, and its replacement is not a rename. chrome.scripting.executeScript takes a single options object with a target, accepts a function or files but never a code string, returns results in a different shape, and can run code in the page’s main world. Code that built JavaScript strings with template literals — the most common MV2 pattern — must be restructured, and code that depended on the result being “the value of the last expression” must return explicitly. This guide walks through the mapping. It belongs to Manifest V2 to V3 migration.

How the new API differs

The MV2 call tabs.executeScript(tabId, { code, file, allFrames, frameId, runAt }) evaluated a string or file and resolved to an array of the last expression’s value in each frame. The MV3 call scripting.executeScript({ target: { tabId, frameIds, allFrames, documentIds }, func, args, files, world, injectImmediately }) serialises a function from your packaged code, structured-clones its arguments separately, runs it, and resolves to an array of { frameId, documentId, result } objects where result is the function’s return value (awaited if it returns a promise). There is no code string because MV3 forbids executing code that was not reviewed in the package; the func must be self-contained because only its source text travels, not its closure.

tabs.* injection versus scripting.*Comparison of MV2 tabs.executeScript, insertCSS and removeCSS with MV3 scripting equivalents on argument shape, code strings, return values, world and frame targeting.AspectMV2 tabs.*MV3 scripting.*Arguments(tabId, details)({ target, … })Code stringscode: "…"func + argsReturn valueLast expression[{frameId, result}]Main worldVia injected <script>world: "MAIN"Remove CSStabs.removeCSSscripting.removeCSSPermissionHost access"scripting" + host access
Same capability, stricter inputs, richer outputs.

Step-by-step: convert each call

1. Add the permission

1{ "permissions": ["scripting", "activeTab"] }

Execution context: the manifest. scripting produces no install warning on its own; access to the target tab still comes from host permissions or an activeTab grant. Without scripting, the chrome.scripting namespace is undefined.

2. Convert file injections — the easy case

1// MV2
2chrome.tabs.executeScript(tabId, { file: "content.js", allFrames: true, runAt: "document_idle" });
3
4// MV3
5await chrome.scripting.executeScript({
6  target: { tabId, allFrames: true },
7  files: ["content.js"],
8});

Execution context: the service worker. files takes an array and paths are relative to the extension root. runAt has no equivalent for one-off injection — the script runs as soon as possible, after document_idle if the document is still loading — and injectImmediately: true asks for it to run without waiting. For scripts that must run at a specific stage on every page load, register a content script instead.

3. Convert code strings to functions with arguments

 1// MV2 — string building, injection-prone
 2const selector = userInput;
 3chrome.tabs.executeScript(tabId, {
 4  code: `document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => el.remove())`,
 5});
 6
 7// MV3 — packaged function, data passed separately
 8await chrome.scripting.executeScript({
 9  target: { tabId },
10  func: (sel) => {
11    let n = 0;
12    for (const el of document.querySelectorAll(sel)) { el.remove(); n++; }
13    return n;
14  },
15  args: [selector],
16});

Execution context: the function runs in the tab’s isolated world; the call is made from the service worker. The function must not reference any variable from the surrounding scope — only its parameters, page globals and the content-script chrome.* subset are available. Arguments must be structured-cloneable (no functions, DOM nodes or class instances with methods). Passing data as args instead of splicing it into source text also eliminates the script-injection bug the MV2 version was one JSON.stringify away from.

What travels to the tabThe worker passes a function and arguments; Chrome sends the function's source text and the structured-cloned arguments, the tab compiles and calls the function, and the return value is cloned back.Service workerBrowserTabfunc, args: [selector]source text of funcclone(args)func(selector) …[{frameId:0, result:3}]
Closures do not travel — only the function's text and its arguments.

4. Read results from the new shape

1// MV2
2chrome.tabs.executeScript(tabId, { code: "document.title" }, ([title]) => console.log(title));
3
4// MV3
5const results = await chrome.scripting.executeScript({
6  target: { tabId, allFrames: true },
7  func: () => ({ title: document.title, url: location.href }),
8});
9for (const { frameId, result } of results) console.log(frameId, result);

Execution context: the service worker. Results are ordered with the main frame first, but use frameId rather than position when you inject into several frames. If the function returns a promise, Chrome awaits it and returns the resolved value. A frame where the function threw yields an entry with an error in recent Chrome versions instead of failing the whole call; older versions reject the call.

5. Convert CSS insertion and removal

1// MV2
2chrome.tabs.insertCSS(tabId, { code: ".ad{display:none}" });
3chrome.tabs.removeCSS(tabId, { code: ".ad{display:none}" });
4
5// MV3
6const css = ".ad{display:none}";
7await chrome.scripting.insertCSS({ target: { tabId }, css });
8await chrome.scripting.removeCSS({ target: { tabId }, css });     // must match exactly

Execution context: the service worker. CSS strings remain allowed — CSS is not code. removeCSS removes only a stylesheet inserted with identical css (or the same files) and the same target, so keep the string in one constant. See removing injected CSS with removeCSS.

6. Replace the MV2 main-world trick with world: “MAIN”

MV2 extensions reached the page’s own JavaScript by injecting a <script> element from a content script. MV3 supports it directly.

1await chrome.scripting.executeScript({
2  target: { tabId },
3  world: "MAIN",
4  func: () => window.__APP_STATE__?.user?.id ?? null,
5});

Execution context: the page’s main world, sharing globals with the site’s scripts and without access to chrome.*. Return values still come back to the worker. Treat anything read from the main world as untrusted — the page controls it. See bridging data between main world and isolated world.

Which scripting call replaces this tabs call?Decision tree mapping MV2 injection calls to MV3: file injections become files, code strings become func with args, CSS stays as css strings, and script-tag main-world tricks become world MAIN.What did the MV2 call inject?filefiles: [ … ]mechanicaltarget.allFramesif neededcode stringfunc + argsrestructureReturn explicitlyno last-expressionCSSinsertCSS({css})strings allowedSame string to removekeep a constant<script> tagworld: "MAIN"direct supportNo chrome.* thereuntrusted results
Only code strings need real restructuring; the rest are mechanical.

Common mistakes

  • Referencing outer variables inside func. The function is serialised as text; a reference to a worker variable becomes a ReferenceError in the tab. Pass everything through args.
  • Expecting the last expression’s value. MV3 returns what the function returns. A function body ending in an expression without return yields undefined.
  • Passing non-cloneable arguments. Functions, Map with function values, and class instances lose their methods or throw. Pass plain data.
  • Using executeScript where a content script belongs. If code must run on every matching page load, register it with registerContentScripts rather than injecting from tabs.onUpdated.
  • Ignoring errors on restricted pages. Injection into chrome:// pages, the Web Store or PDF viewers fails. Catch and report rather than crashing the handler — see handling injection errors on restricted pages.

Cross-browser variation

  • Chrome / Edge: chrome.scripting from Chrome 88; world: "MAIN" from 95; per-frame error entries in results in recent versions.
  • Firefox: browser.scripting from Firefox 102 with the same shapes. Firefox also kept tabs.executeScript for MV2 extensions, which is why mixed code often appears in cross-browser projects.
  • Safari: supports scripting.executeScript, insertCSS and removeCSS; world: "MAIN" support arrived later than in Chrome, so feature-detect and fall back to a script element injected from the isolated world.

Verification

  1. Grep for tabs.executeScript, tabs.insertCSS and tabs.removeCSS; the count should be zero.
  2. For each converted call, invoke the feature on a normal page and confirm the result shape in the worker console.
  3. Invoke each feature on chrome://extensions and confirm a handled error, not an uncaught rejection.
  4. For main-world calls, confirm the result matches what the page’s own console shows for the same global.

FAQ

Can I still pass a long script as a function?

Yes — any function works, but long logic is better as a packaged file in files, which keeps it testable and avoids re-serialising source on every call.

Can the injected function use imports?

No. It runs as a classic script body. Bundle helpers into a file and inject that file first, then call into it with a second func injection.

Does files support ES modules?

Injected files run as classic scripts. Bundle module code into a single file before injecting.

How do I inject into a specific frame I found earlier?

Use target: { tabId, frameIds: [frameId] } with a frame id from chrome.webNavigation.getAllFrames or from a previous result. Newer Chrome versions also accept documentIds, which stay valid only for one document and so cannot accidentally target a frame that has since navigated — the safer choice when you inject some time after discovering the frame.

Is there still a way to run code before the page’s own scripts?

For one-off injection, injectImmediately: true runs as early as the browser can, but there is no guarantee it beats the page. For reliable early execution, register a content script with runAt: "document_start", which the browser injects before any page script runs.

Other MV3 Architecture & Extension Lifecycle Resources