Injecting CSS and JS with executeScript

Practical guide to chrome.scripting.executeScript and insertCSS in MV3 — passing args, capturing return values, targeting frames, and cleaning up with removeCSS.

Published June 19, 2026 Updated June 19, 2026 8 min read
Table of Contents

You call chrome.scripting.executeScript expecting a return value, and get undefined. Or you pass a variable captured from the outer service-worker scope and the injected function receives undefined instead. These are the two most common mistakes with executeScript in Manifest V3, and both stem from the same root: the injected function is serialised across a process boundary. This guide is part of Scripting API & Dynamic Injection.

Why the process boundary matters

When you write:

1const keyword = "scripting";
2
3await chrome.scripting.executeScript({
4  target: { tabId },
5  func: () => {
6    return document.title.includes(keyword); // ❌ keyword is undefined in the tab
7  },
8});

Execution context: The service worker serialises func via .toString() before sending it to the browser process, which re-evaluates it in the renderer. The closure variable keyword does not travel with the function string — it is undefined on the other side.

The renderer process is a separate OS process from the service worker. The function body is transmitted as a source string; the environment in which it was defined is not. This is not a bug — it is a fundamental security boundary. The fix is always the same: use the args parameter.

1. Passing arguments correctly

The args array is JSON-serialised and deserialised before the function is called. Each element maps positionally to a function parameter.

 1chrome.action.onClicked.addListener(async (tab) => {
 2  if (!tab.id) return;
 3
 4  const keyword = "scripting";
 5  const caseSensitive = false;
 6
 7  const [{ result }] = await chrome.scripting.executeScript({
 8    target: { tabId: tab.id },
 9    func: checkTitle,
10    args: [keyword, caseSensitive],
11  });
12
13  console.log("Title matches:", result); // boolean from the renderer
14});
15
16function checkTitle(keyword: string, caseSensitive: boolean): boolean {
17  const title = caseSensitive ? document.title : document.title.toLowerCase();
18  return title.includes(caseSensitive ? keyword : keyword.toLowerCase());
19}

Execution context: chrome.action.onClicked fires in the service worker. checkTitle is declared at module scope (not inline) — this avoids accidental closure capture. args values must be JSON-serialisable: no Map, Set, RegExp, Date, or class instances. Firefox and Safari serialise args identically.

2. Capturing return values

executeScript returns a Promise<InjectionResult[]> — an array with one entry per frame that was injected. Each entry has a result property containing whatever the function returned (also JSON-serialised on the way back).

 1async function extractAllLinks(tabId: number): Promise<string[]> {
 2  const frames = await chrome.scripting.executeScript({
 3    target: { tabId, allFrames: true },
 4    func: (): string[] =>
 5      Array.from(document.querySelectorAll("a[href]"))
 6        .map((a) => (a as HTMLAnchorElement).href),
 7  });
 8
 9  // Flatten results from all frames, remove duplicates
10  return [...new Set(frames.flatMap((f) => (f.result as string[]) ?? []))];
11}

Execution context: Runs in the service worker; the function runs in each frame’s renderer context. f.result is null if the frame threw an error or navigated during injection. Always guard with ?? []. Firefox behaves identically. Safari may omit sub-frame results for cross-origin iframes even when host permissions are granted — test frame targeting explicitly on Safari.

3. Targeting specific frames

The target object controls which frames receive the injection. By default only the main frame is targeted. Use frameIds when you know the exact frame, or allFrames: true to inject everywhere.

 1// Inject only into a known frame (e.g. from chrome.webNavigation.getAllFrames)
 2const frames = await chrome.webNavigation.getAllFrames({ tabId });
 3const sameOriginFrames = (frames ?? [])
 4  .filter((f) => f.url.startsWith("https://example.com"))
 5  .map((f) => f.frameId);
 6
 7if (sameOriginFrames.length > 0) {
 8  await chrome.scripting.executeScript({
 9    target: { tabId, frameIds: sameOriginFrames },
10    func: () => {
11      document.documentElement.dataset.extInjected = "1";
12    },
13  });
14}

Execution context: Both chrome.webNavigation.getAllFrames and chrome.scripting.executeScript run in the service worker. webNavigation requires the webNavigation permission. frameIds values are not stable across navigations — always re-query if the page may have navigated. Firefox handles frameIds correctly; Safari supports it from Safari 16 but silently drops frames in sandboxed iframes.

4. Inserting CSS with insertCSS

chrome.scripting.insertCSS applies a stylesheet to the tab. It accepts either an inline css string or a files array pointing at extension assets.

 1const OVERLAY_CSS = `
 2  body::after {
 3    content: "";
 4    position: fixed;
 5    inset: 0;
 6    background: rgba(0,0,0,0.35);
 7    pointer-events: none;
 8    z-index: 2147483647;
 9  }
10`;
11
12async function applyReadingOverlay(tabId: number): Promise<void> {
13  await chrome.scripting.insertCSS({
14    target: { tabId },
15    css: OVERLAY_CSS,
16  });
17}

Execution context: Runs in the service worker. The CSS string is transmitted to the renderer and injected into the document’s stylesheet cascade. It behaves identically to a <style> tag appended to <head>. Firefox and Safari support the same call; CSS specificity rules still apply — use !important sparingly and deliberately. High-specificity selectors are safer than !important in page-agnostic extensions.

5. Removing CSS with removeCSS

The removeCSS call reverses an insertCSS injection. The css string must match the original exactly — character for character. Any difference and the browser cannot identify the rule-set to remove.

1async function removeReadingOverlay(tabId: number): Promise<void> {
2  // Must be the exact same string passed to insertCSS
3  await chrome.scripting.removeCSS({
4    target: { tabId },
5    css: OVERLAY_CSS,  // reference the same module-scope constant
6  });
7}

Execution context: Runs in the service worker. Storing the CSS in a shared module-scope constant (not a magic string literal in each call) is the safest way to guarantee the strings match. Firefox supports removeCSS from Firefox 102+; Safari added support in Safari 16.4. If you need to support older Safari versions, inject via a file and remove it by injecting a script that deletes the <style> element instead.

6. Injecting files and handling errors

File-based injection avoids the serialisation constraint entirely. The file is loaded from the extension package and evaluated in the renderer as a normal script.

 1async function injectReaderScript(tabId: number): Promise<boolean> {
 2  try {
 3    await chrome.scripting.executeScript({
 4      target: { tabId },
 5      files: ["content/reader.js"],
 6    });
 7    return true;
 8  } catch (err) {
 9    // Common causes: no permission for that URL, tab navigated, chrome:// page
10    const msg = (err as Error).message ?? "";
11    if (msg.includes("Cannot access") || msg.includes("No tab with id")) {
12      console.warn("Injection skipped:", msg);
13      return false;
14    }
15    throw err; // unexpected error — surface it
16  }
17}

Execution context: Runs in the service worker. The try/catch is required — unlike many Chrome APIs, executeScript rejects its Promise on protected URLs rather than calling an error callback. Firefox and Safari throw the same class of error; the message text differs, so test for known substrings rather than the full message. For more complex scenarios involving dynamic iframes, see injecting content scripts into dynamic iframes.

Cross-browser variation

  • Chrome/Edge: Full support for executeScript with func/args/files, insertCSS, removeCSS, allFrames, and frameIds. The world parameter (ISOLATED/MAIN) is fully supported.
  • Firefox: browser.scripting.executeScript with native Promises. args serialisation is identical. world: "MAIN" is supported from Firefox 102, but was behind a flag in early releases — test your minimum supported version. removeCSS is available from Firefox 102.
  • Safari: browser.scripting.executeScript via WebKit. world: "MAIN" requires Safari 17.0+. removeCSS requires Safari 16.4+. Cross-origin frameIds targeting is unreliable in Safari — prefer allFrames: true with in-frame origin guards. Safari’s content security policy may block scripts that call eval or create Function objects inside injected code.

Verification

  1. Open DevTools on the target page (not the extension background) and check the Console tab for errors — injected scripts log there, not in the service worker console.
  2. Use the Sources panel to search for your injected function name — Chrome adds it to the source tree under Extensions/.
  3. In the service worker DevTools, console.log the result array from executeScript — if any InjectionResult.result is null, the frame threw during execution.
  4. To verify CSS injection, open Elements > Styles and look for a stylesheet with the source label injected stylesheet or your extension’s name.

FAQ

Why does my function return undefined even when I return a value?

The most likely cause is that the function throws before reaching the return statement — possibly a DOM API that does not exist in the target frame. Wrap the function body in try/catch and return the error message as a string to diagnose.

Can I pass a class instance in args?

No. args are JSON-serialised, so only plain objects, arrays, strings, numbers, booleans, and null survive. Convert class instances to plain objects before passing them, and reconstruct them inside the injected function.

Does allFrames: true inject into cross-origin iframes?

Only if you hold a matching host permission for the iframe’s origin. activeTab does not extend to sub-frames on different origins. Without the permission the frame is silently skipped — no error is thrown.

How do I pass data from an injected MAIN-world script back to the service worker?

The injected code cannot call chrome.runtime.sendMessage from world: "MAIN". Use a two-step approach: the MAIN-world script communicates back to an ISOLATED-world content script via window.postMessage, and the ISOLATED script forwards the message to the service worker using chrome.runtime.sendMessage. See the message passing architecture for the full pattern.

Other Core APIs & Cross-Browser Data Management Resources