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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

What survives the trip backTypes returned from an injected function and whether they arrive in the extension intact, partially, or not at all.Returned valueArrives asDo insteadPlain object / arrayIntact—PromiseResolved value—Map, Set, Date, ArrayBufferIntact—DOM elementLostReturn its dataFunctionLost / nullDon'tClass instanceOwn fields onlyReturn a plain object
Return plain data; convert everything else inside the page.

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.

From injected function to result in the workerThe worker calls executeScript; the browser injects into each target frame; each frame runs the function, awaits any promise and structured-clones the value; results return as an array with frame ids.Service workerBrowserFrame 0Frame 4executeScript({allFrames:true, func})run funcrun funcclone(result)clone(result)[{frameId:0,…},{frameId:4,…}]
One entry per frame, cloned — never a live reference into the page.

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.

Why is my result undefined?Decision tree for an undefined executeScript result: the function did not return, the value was not cloneable, the frame was not the one inspected, or the injection failed for that frame.What does the result array show?result undefinedNo returnor files' last statementAdd returnexplicitlyresult null / {}Not cloneableDOM node, functionReturn plain dataconvert in pagewrong entryOther frameallFrames orderingMatch by frameIdnot indexerror fieldThrew in frameexceptionCatch inside funcreturn error data
Most undefined results are a missing return or an uncloneable value.

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 return in func. 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 by frameId.
  • 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: InjectionResult with frameId, documentId and per-frame error in recent versions; promises awaited.
  • Firefox: browser.scripting.executeScript returns { frameId, result }; an exception in a frame may reject the whole call or appear as an error depending on version.
  • Safari: returns results per frame; world: "MAIN" support arrived later than in Chrome — feature-detect.

Verification

  1. Inject a function returning { a: 1, el: document.body } and confirm a arrives and el does not.
  2. Inject an async function that resolves after 100 ms and confirm the resolved value arrives.
  3. Inject with allFrames on a page with iframes and confirm one entry per accessible frame.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources