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.
Table of Contents
The func option looks like it takes a closure. It does not: the function is serialised to source, sent to the page’s renderer, and recompiled there, so every variable it referenced from the surrounding scope is simply gone. The symptom is a ReferenceError in a frame you are not watching, or — worse — a silent undefined where you expected the user’s setting. This guide is part of scripting API dynamic injection.
Why the closure disappears
Step-by-step
1. Pass data through args, never through scope
1// Broken — `selector` is not in scope inside the page.
2const selector = ".price";
3await chrome.scripting.executeScript({
4 target: { tabId },
5 func: () => document.querySelector(selector)?.textContent, // ReferenceError
6});
7
8// Correct — the value travels as an argument.
9await chrome.scripting.executeScript({
10 target: { tabId },
11 func: (sel) => document.querySelector(sel)?.textContent,
12 args: [selector],
13});
Execution context: the func body runs in the content script’s isolated world in the target frame — it has the page’s DOM but not the page’s JavaScript variables, and only a small part of the chrome.* surface. The surrounding call runs in the service worker.
2. Respect the structured-clone boundary
args goes through the structured clone algorithm. Plain objects, arrays, Date, Map, Set, ArrayBuffer and typed arrays all survive. Functions, DOM nodes, class instances and anything with a prototype you care about do not.
1// Survives
2args: [{ selector: ".price", limit: 5, since: new Date(), ids: new Set([1, 2]) }]
3
4// Does not survive
5args: [{ onFound: (el) => el.click() }] // DataCloneError
6args: [document.body] // DataCloneError
7args: [new PriceParser()] // arrives as a plain object, methods gone
Execution context: the serialisation happens in the worker before the call leaves. A DataCloneError here rejects the executeScript promise, so it is at least loud — the silent case is a class instance that clones into a shape without its methods.
3. Read the return value back
executeScript resolves with one result per injected frame, and each result’s result field is the function’s return value, cloned back.
1const results = await chrome.scripting.executeScript({
2 target: { tabId, allFrames: true },
3 func: (sel) => ({ href: location.href, text: document.querySelector(sel)?.textContent ?? null }),
4 args: [".price"],
5});
6
7for (const { frameId, result } of results) {
8 console.log(frameId, result.href, result.text);
9}
Execution context: the worker receives the array; each func ran in its own frame. A frame that threw contributes an entry with result: undefined and an error field rather than rejecting the whole call.
4. Return a promise for async work
If the injected function returns a promise, the browser waits for it and clones the resolved value.
1const [{ result }] = await chrome.scripting.executeScript({
2 target: { tabId },
3 func: async (timeoutMs) => {
4 const el = await new Promise((resolve) => {
5 const found = document.querySelector("#total");
6 if (found) return resolve(found);
7 const obs = new MutationObserver(() => {
8 const el = document.querySelector("#total");
9 if (el) { obs.disconnect(); resolve(el); }
10 });
11 obs.observe(document.body, { childList: true, subtree: true });
12 setTimeout(() => { obs.disconnect(); resolve(null); }, timeoutMs);
13 });
14 return el?.textContent ?? null;
15 },
16 args: [3000],
17});
Execution context: the isolated world of the target frame, where MutationObserver and setTimeout are the page’s own — they are not subject to the worker’s idle timer, but the worker does stay alive while the executeScript promise is pending.
5. Keep large payloads out of args
Every argument is copied into the renderer. For anything more than a few kilobytes, write it to storage in the worker and read it in the page.
1// Worker
2await chrome.storage.session.set({ "inject:rules": bigRuleSet });
3await chrome.scripting.executeScript({
4 target: { tabId },
5 func: async () => {
6 const { "inject:rules": rules } = await chrome.storage.session.get("inject:rules");
7 return applyRules(rules);
8 },
9});
Execution context: the injected function runs in the isolated world, where chrome.storage is available — one of the few chrome.* namespaces content scripts can use. applyRules must be defined inside the injected function or shipped as a file; it cannot be referenced from the worker’s scope.
6. Prefer files once the function grows
A function big enough to need helpers belongs in a file. files and func are mutually exclusive, so pass data through storage or a follow-up message instead of args.
1await chrome.scripting.executeScript({
2 target: { tabId },
3 files: ["content/extract.js"],
4});
5const data = await chrome.tabs.sendMessage(tabId, { type: "extract:run", selector });
Execution context: the file runs in the isolated world and registers its own onMessage listener; the follow-up message carries the parameters. This also keeps the code lintable and testable, unlike a stringified function.
Errors, partial failures and reading the result array
executeScript resolves with an array even when things go wrong, and the array is the only place the failure is reported. A frame whose function threw contributes an entry with result: undefined; on recent Chrome it also carries an error field. A frame that could not be injected at all contributes nothing — the array is simply shorter.
That gives three distinct outcomes to handle, and code that checks only results[0].result conflates all of them:
1async function extract(tabId, selector) {
2 const results = await chrome.scripting.executeScript({
3 target: { tabId, allFrames: true },
4 func: (sel) => {
5 const el = document.querySelector(sel);
6 if (!el) throw new Error(`no match for ${sel}`);
7 return el.textContent.trim();
8 },
9 args: [selector],
10 });
11
12 const ok = results.filter((r) => r.result !== undefined);
13 const threw = results.filter((r) => r.result === undefined);
14 return { values: ok.map((r) => r.result), failedFrames: threw.map((r) => r.frameId) };
15}
Execution context: the service worker. The whole call rejects only for errors outside the injected function — a bad target, a missing permission, a clone failure on the arguments. Anything the function itself throws is per-frame.
The subtler failure is a return value that cannot be cloned. document.querySelector(sel) returns an element, and returning it directly does not throw inside the page — it fails during the clone back, and the frame reports undefined exactly as if the function had thrown. Whenever a result is mysteriously empty on a page you can see has the element, check that the function returns a primitive or a plain object rather than a DOM node.
1// Wrong: the element cannot cross back.
2func: (sel) => document.querySelector(sel)
3
4// Right: return what you actually need.
5func: (sel) => {
6 const el = document.querySelector(sel);
7 return el ? { text: el.textContent.trim(), href: el.getAttribute("href") } : null;
8}
Execution context: the isolated world of each target frame. Returning null rather than undefined for “not found” is worth the discipline: it distinguishes a clean miss from a frame that failed, which undefined cannot.
Cross-browser variation
- Chrome / Edge:
argsandfuncare supported from Chrome 92. ADataCloneErrorrejects the call with a message naming the offending value, which is the most helpful diagnostic of the three engines. - Firefox:
browser.scripting.executeScriptsupportsfuncandargsfrom Firefox 102. Firefox’s clone implementation is slightly stricter aboutSymbolkeys — drop them rather than relying on them being ignored. - Safari: supported from Safari 16.4. Safari’s error reporting for a failed clone is vaguer, often surfacing as a generic rejection, so validate argument shapes yourself in a debug build.
- All three: the injected function’s own
thisis the page’sglobalThisfor the isolated world, not your worker. Arrow functions inherit nothing useful from the worker’s scope, so do not rely onthisat all.
Verification
- Confirm the round trip with a value you can see:
1const [{ result }] = await chrome.scripting.executeScript({
2 target: { tabId },
3 func: (a, b) => ({ sum: a + b, url: location.href }),
4 args: [2, 3],
5});
6result; // { sum: 5, url: "https://example.com/" }
Execution context: the service worker console. If result is undefined, the function threw — check the same entry’s error field, which carries the message from the page.
- Deliberately pass a function in
argsand confirm the call rejects rather than silently dropping it. - Inject with
allFrames: trueand confirm you get one result object per reachable frame, each with its ownframeId.
FAQ
Can I pass a chrome.* object as an argument?
No. Those are host objects and will not clone. The isolated world already has its own chrome.storage, chrome.runtime and chrome.i18n — use those directly inside the function.
Why is my imported helper undefined inside func?
Because only the function’s own source crosses the boundary. Imports, module scope and outer variables are all left behind. Move the helper inside the function, or switch to files.
Does the return value have a size limit?
Not a documented one, but it is cloned through the same channel as the arguments and large returns are slow. Anything over a few hundred kilobytes belongs in storage with the key returned instead.
Related
- Injecting CSS and JS with executeScript — the injection call itself.
- Injecting into iframes and all frames — one result per frame, and why counts differ.
- Bridging data between main world and isolated world — crossing the second boundary, into the page’s own scope.
- Scripting API dynamic injection — the parent guide to injection in MV3.