Getting Return Values from executeScript
Read results from chrome.scripting.executeScript in MV3: the InjectionResult array, async functions, frame ordering, structured-clone limits, errors in results, and returning large or binary data safely.
Table of Contents
You inject a function to read something from the page — the selected text, a JSON-LD block, the article’s word count — and the result is undefined, or [object Object] with every useful field missing, or a promise instead of a value. chrome.scripting.executeScript does return the injected function’s result, but through a specific shape and a serialisation step with rules of its own. Knowing those rules turns “why is this undefined?” into a predictable pattern. This guide covers them. It belongs to scripting API and dynamic injection.
The result shape and the clone step
executeScript resolves to an array of InjectionResult objects, one per frame the script ran in: { frameId, documentId, result }, plus an error field in recent Chrome versions when the script threw in that frame. result is the return value of func — or, for files, the value of the last statement executed — after being serialised with the structured clone algorithm and sent back to the extension. If func returns a promise, the browser waits for it and returns the resolved value. Structured clone handles plain objects, arrays, strings, numbers, booleans, null, Date, Map, Set, ArrayBuffer and typed arrays. It cannot clone functions, DOM nodes, Window, Error objects with their full fields in all engines, or objects with getters that live on prototypes — those are dropped or cause the result to be null.
Step-by-step: reliable results
1. Return plain data explicitly
1// sw.js
2const [{ result }] = await chrome.scripting.executeScript({
3 target: { tabId },
4 func: () => ({
5 title: document.title,
6 url: location.href,
7 words: document.body.innerText.trim().split(/\s+/).length,
8 lang: document.documentElement.lang || null,
9 }),
10});
Execution context: the function runs in the tab’s isolated world; the call is made from the service worker. Destructuring the first element works when you target only the top frame. Building a plain object of exactly the fields you need, inside the page, is both the most reliable and the cheapest — only those fields are cloned and sent.
2. Use async functions for asynchronous work
1const [{ result: meta }] = await chrome.scripting.executeScript({
2 target: { tabId },
3 func: async () => {
4 const ld = [...document.querySelectorAll('script[type="application/ld+json"]')]
5 .map((s) => { try { return JSON.parse(s.textContent); } catch { return null; } })
6 .filter(Boolean);
7 const img = document.querySelector('meta[property="og:image"]')?.content ?? null;
8 await new Promise((r) => requestAnimationFrame(r)); // let late scripts settle
9 return { ld, img };
10 },
11});
Execution context: the tab’s isolated world. Chrome awaits the returned promise and returns its resolved value. A rejected promise produces an error for that frame. Keep async work short — the injection holds open until it settles, and a promise that never resolves leaves the call hanging.
3. Handle multiple frames by frameId
1const results = await chrome.scripting.executeScript({
2 target: { tabId, allFrames: true },
3 func: () => getSelection()?.toString() ?? "",
4});
5const selected = results.find((r) => r.result)?.result ?? "";
6const fromFrame = results.find((r) => r.result)?.frameId;
Execution context: the service worker. With allFrames, results arrive for every frame the extension can access — the main frame first in practice, but rely on frameId, not position. Frames without permission or with unsupported URLs are simply absent. A typical use is finding which frame holds the user’s selection.
4. Check per-frame errors
1for (const r of results) {
2 if (r.error) console.warn(`frame ${r.frameId} failed:`, r.error);
3}
Execution context: the service worker. Recent Chrome versions report an exception in one frame as an error on that frame’s result instead of rejecting the whole call, so one broken iframe does not hide results from the others. Older versions — and some other engines — reject the call if any frame throws. Wrap the function body in try/catch and return an { ok, value | error } object yourself if you need identical behaviour everywhere.
5. Return large or binary data efficiently
1const [{ result: bytes }] = await chrome.scripting.executeScript({
2 target: { tabId },
3 func: async () => {
4 const canvas = document.querySelector("canvas#chart");
5 const blob = await new Promise((r) => canvas.toBlob(r, "image/png"));
6 return new Uint8Array(await blob.arrayBuffer()); // typed arrays clone efficiently
7 },
8});
9const blob = new Blob([bytes], { type: "image/png" });
Execution context: the tab’s isolated world, returning to the worker. Typed arrays and ArrayBuffers are structured-cloneable and avoid the 33% overhead of base64. Blobs themselves are not reliably returned across all engines, so convert to bytes. For very large payloads (many megabytes), consider writing to the page’s clipboard or using a port from a content script to stream chunks instead.
6. Read results from main-world functions
1const [{ result: version }] = await chrome.scripting.executeScript({
2 target: { tabId },
3 world: "MAIN",
4 func: () => window.__APP__?.version ?? null,
5});
Execution context: the page’s main world, where page globals are visible. Results come back the same way. Treat them as untrusted: the page controls its globals and can return anything, including values crafted to exploit your extension’s handling. Validate before use. See bridging data between main world and isolated world.
7. Prefer a content script for ongoing reads
If you read the same data repeatedly — every time the page changes — injecting a function each time is wasteful. A content script that watches the page and messages the worker when something changes is cheaper and gives fresher data. executeScript with a return value is best for one-off reads triggered by a user action.
Common mistakes
- No
returninfunc. Arrow functions with braces need an explicit return. - Returning DOM nodes. They cannot be cloned; return their data.
- Assuming
results[0]is the frame you want. Match byframeId. - Unhandled promise in
func. A promise that never settles hangs the call. - Trusting main-world results. Validate them like any page input.
Cross-browser variation
- Chrome / Edge:
InjectionResultwithframeId,documentIdand per-frameerrorin recent versions; promises awaited. - Firefox:
browser.scripting.executeScriptreturns{ frameId, result }; an exception in a frame may reject the whole call or appear as anerrordepending on version. - Safari: returns results per frame;
world: "MAIN"support arrived later than in Chrome — feature-detect.
Verification
- Inject a function returning
{ a: 1, el: document.body }and confirmaarrives andeldoes not. - Inject an async function that resolves after 100 ms and confirm the resolved value arrives.
- Inject with
allFrameson a page with iframes and confirm one entry per accessible frame. - Throw inside the function in one frame and confirm the other frames’ results still arrive (Chrome) or that your wrapper reports the error.
FAQ
Can I return a value from an injected file?
Yes — the last evaluated statement’s value is returned, which is fragile. Prefer func.
Is there a size limit?
No fixed limit is documented, but very large clones are slow and memory-heavy; keep results small.
Can the injected function call chrome.* APIs?
In the isolated world, the content-script subset (runtime.sendMessage, storage, i18n) is available; in the main world, none are.
How do I return an error from the page without throwing?
Wrap the function body: try { return { ok: true, value: work() }; } catch (e) { return { ok: false, message: String(e) }; }. A plain object with a message string survives cloning in every engine, unlike an Error object, and the worker can treat it like any other result.
Why is the result different between Chrome and Firefox?
Usually because of cloning differences for unusual types or because one engine rejected the whole call on a frame error. Returning plain objects of primitive values gives identical results everywhere.
Related
- Passing arguments to injected functions — the other direction.
- Handling injection errors on restricted pages — when the call itself fails.
- Replacing tabs.executeScript with chrome.scripting — migrating old result handling.
- Scripting API and dynamic injection — the parent topic.