chrome.scripting.executeScript in Manifest V3: replacing tabs.executeScript
6 min readModerok team
Why chrome.tabs.executeScript is not a function in MV3, the chrome.scripting.executeScript call that replaces it, and what still breaks after.
If a Manifest V3 extension throws TypeError: chrome.tabs.executeScript is not a function, that method does not exist in MV3. Declare the scripting permission and call chrome.scripting.executeScript instead:
{
"manifest_version": 3,
"permissions": ["scripting", "activeTab"]
}
// tabId comes from chrome.tabs.query() or from the event that triggered you
await chrome.scripting.executeScript({
target: { tabId },
func: (color) => { document.body.style.backgroundColor = color; },
args: ["#ffeb3b"],
});
That is the fix. The rest is why the API moved and what still fails once the TypeError is gone.
Why tabs.executeScript is gone
Two changes landed together. The first is a rename, stated plainly in Chrome's security migration guide: "The executeScript() method is now in the scripting namespace rather than the tabs namespace" (Improve extension security).
The second is a capability removal, and it is the one that breaks real code: "You can no longer execute external logic using executeScript(), eval(), and new Function()." The MV2 code option has no counterpart on ScriptInjection. If your old call was chrome.tabs.executeScript(tabId, { code: 'document.title' }), there is no property to move that string into: the logic has to become a function in your bundle or a file shipped inside the package. Code strings did not vanish from MV3 altogether, they moved to the user scripts API, which injects a ScriptSource whose code is documented as "A string containing the JavaScript code to inject" (chrome.userScripts reference). That sits behind the userScripts permission and is meant for scripts your users supply.
The permissions also changed. Chrome's migration checklist for this call lists exactly two requirements: the scripting permission, and either host permissions or the activeTab permission (Update your code). scripting on its own injects nothing.
The call shape changed, not just the namespace
The old signature took the tab id positionally and an InjectDetails object second. The new one takes a single object. Chrome's migration guide describes passing "a ScriptInjection object instead of InjectDetails", and notes that "the tabId is now passed as a member of ScriptInjection.target instead of as a method argument".
The property names are where most of the lost hours go. The reference documents six members on ScriptInjection: target, files, func, args, world and injectImmediately (chrome.scripting reference). There is no code, and the argument list is args, not arguments. The files entry carries the constraint that trips people combining the two: "Exactly one of files or func must be specified." args is documented as "The arguments to pass to the provided function. This is only valid if the func parameter is specified." It belongs only in a func injection.
files is an array now; the MV2 call took a single file.
func is serialized, so the closure does not travel
This failure looks like a bug in your own code. The reference is explicit: "This function will be serialized, and then deserialized for injection. This means that any bound parameters and execution context will be lost."
So this breaks: the function arrives without selector, which was part of the execution context the reference says is lost (tabId is in scope below):
const selector = ".price";
await chrome.scripting.executeScript({
target: { tabId },
func: () => document.querySelector(selector)?.textContent,
});
Everything the injected function needs has to arrive through args, and the reference constrains those: "These arguments must be JSON-serializable." A Map, a Date, or a function will not survive the trip.
const selector = ".price";
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (sel) => document.querySelector(sel)?.textContent ?? null,
args: [selector],
});
There is a syntax trap in the serialization too. MDN: "A function defined using method syntax, such as method() {} in an object literal or class, doesn't serialize to a valid function expression, so it fails to run. Firefox returns a SyntaxError in the error property of the InjectionResult, while Chrome only reports the error in the target tab's console" (scripting.executeScript()). In Chrome that is a silent failure from the service worker's point of view, with the evidence in a console you are not looking at.
Reading the return value
executeScript() resolves to an array, not a value. The reference: "A single result is included per-frame. The main frame is guaranteed to be the first index in the resulting array; all other frames are in a non-deterministic order."
That guarantee is why destructuring the first entry is safe for a single-frame injection, and why you should match on frameId when injecting with allFrames: true.
Async injected functions work. MDN: "If the last statement produces a Promise, the result is the settled value of that Promise." The result still crosses a process boundary, and the rule differs by browser: "The script result must be a structured cloneable value in Firefox or a JSON-serializable value in Chrome." Return a primitive or a plain object; a DOM node survives neither algorithm.
ISOLATED and MAIN worlds
The optional world property decides which JavaScript environment the injection lands in. The reference defines both. ISOLATED: "Specifies the isolated world, which is the execution environment unique to this extension." MAIN: "Specifies the main world of the DOM, which is the execution environment shared with the host page's JavaScript."
The default is ISOLATED, which is why a function reading a window.__APP_STATE__ set by the page returns undefined until you add world: "MAIN". The reverse applies too: in MAIN you share globals with the page.
What still breaks after the fix
Host permissions. scripting grants the API, not the page. MDN: "To use this API you must have the "scripting" permission and permission for the target's URL, either explicitly as a host permission or using the activeTab permission." The failure surfaces as a permission error naming the host it could not reach, which has its own set of causes.
Pages no permission unlocks. MDN: "some special pages do not allow this permission, including reader view, view-source, PDF viewer, and other built-in browser UI pages."
Calling it from a content script. chrome.scripting is not in the set content scripts can reach. Chrome's content script docs: "Content scripts are unable to access other APIs directly. But they can access them indirectly by exchanging messages with other parts of your extension." Message the service worker and inject from there.
Target and timing options. allFrames is documented as "This must not be true if frameIds is specified," so pick one. And injectImmediately is weaker than it sounds: "Note that this is not a guarantee that injection will occur prior to page load, as the page may have already loaded by the time the script reaches the target."
Navigation. An injection is a one-off against the document loaded now. It does not reapply after the user navigates or after a single-page app swaps its view. For injections that should persist, use chrome.scripting.registerContentScripts(), which also survives the service worker shutting down between calls.
Firefox differences
MDN's tabs.executeScript() page carries the redirect: "When using Manifest V3 or higher, use scripting.executeScript() to execute scripts" (MDN).
The difference that matters for multi-frame injections is how a partial permission grant is treated. MDN: "In Firefox and Safari, partial lack of host permissions can result in a successful execution (with the partial results in the resolved promise). In Chrome, any missing permission prevents any execution from happening". A call that returns results for three of five frames in Firefox rejects outright in Chrome, so cross-browser code has to handle a partial array and no array at all. The two other divergences are above: structured clone versus JSON for the result, and where the method-syntax SyntaxError surfaces.
Seeing the rejection in production
The permission failures, restricted pages and frame-target mistakes above all reject the returned promise rather than throwing where you can see them. (The content script case is the exception: chrome.scripting is undefined there, so that one fails at the call site.) Without a .catch() a rejection goes unhandled in a context with no visible console, and the only symptom users report is that the button did nothing.
Moderok's SDK records uncaught exceptions and unhandled promise rejections as error events by default in any context where you call Moderok.init(), so a bare chrome.scripting.executeScript() that rejects on a restricted tab shows up without extra code. When you do catch it, Moderok.captureError(error, { action: "inject_overlay" }) keeps the context that separates a permission failure from a serialization one. The error tracking guide covers both.