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.
Practical guide to chrome.scripting.executeScript and insertCSS in MV3 — passing args, capturing return values, targeting frames, and cleaning up with removeCSS.
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.
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.
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.
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.
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.
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.
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.
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.
executeScript with func/args/files, insertCSS, removeCSS, allFrames, and frameIds. The world parameter (ISOLATED/MAIN) is fully supported.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.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.Extensions/.console.log the result array from executeScript — if any InjectionResult.result is null, the frame threw during execution.injected stylesheet or your extension’s name.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.
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.
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.
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.
registerContentScripts, world selection, and permission strategy.Chrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.
Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.
Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.