Bridging Data Between MAIN World and Isolated World

Move data between an injected MAIN-world script and your isolated content script safely: CustomEvent handshakes, origin checks, request correlation, and what not to expose.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

Your world: "MAIN" script can read the page’s framework state, and it cannot call chrome.runtime. Your isolated content script can call chrome.runtime, and it cannot see a single page variable. The data you need is on one side and the extension is on the other, and the only thing they share is the DOM. This page is part of Scripting API & Dynamic Injection and builds the bridge between them without handing the page a way into your extension.

Root cause: two V8 contexts, one document

The isolated world exists precisely so a hostile page cannot reach your extension. The MAIN world exists precisely so you can reach the page. Bridging them re-opens a door the browser deliberately closed, so the bridge has to be narrow, one-directional in intent, and suspicious of everything crossing it.

What each side can reach, and the single shared surfaceThe MAIN world sees page globals but no extension APIs; the isolated world sees extension APIs but no page globals; both see the same DOM.Page and MAIN-world scriptwindow.__APP_STATE__, page librariesthe page can read and forge events hereShared DOMdocument, CustomEvent, data attributesthe only channelIsolated content scriptchrome.runtime, chrome.storagetreat inbound data as untrustedService workerhost permissions, network, persistencenever exposed to the page
The DOM is the bridge — and it is also the channel the page itself can listen on.

Step 1. Inject the MAIN-world script from the worker

The isolated script cannot create a MAIN-world script by appending a <script> tag — the extension content security policy blocks inline code, and a remote source is refused at review. Inject it properly.

1// sw.js
2chrome.action.onClicked.addListener(async (tab) => {
3  await chrome.scripting.executeScript({
4    target: { tabId: tab.id },
5    files: ["bridge-main.js"],
6    world: "MAIN",
7    injectImmediately: true,
8  });
9});

Execution context: Service worker; requires the scripting permission plus host access or activeTab. injectImmediately matters when the page’s own state is created early — the default waits for the document to settle, by which time a single-page application may have replaced the object you wanted to read. The file must also be listed in web_accessible_resources if the page could navigate to it directly; injecting by file rather than by string is what keeps the extension reviewable, as covered in safely using remote config without remote code.

Step 2. Define one event pair and one correlation id

Two event names, a request id, and nothing else. A general-purpose message bus across this boundary is a vulnerability with a nice API.

1// bridge-protocol.js — a plain module, imported into both bundles
2export const REQUEST = "tabcurator:req";
3export const REPLY   = "tabcurator:res";
4
5export function newId() {
6  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
7}

Execution context: Build-time module, bundled into both the MAIN-world file and the isolated content script — it contains no API calls, so it is safe in either. Namespacing the event names with your extension avoids collisions with the page’s own events, which do exist: generic names like message or data are used by real sites and will produce events you did not send.

Step 3. Answer requests on the MAIN side, narrowly

The MAIN-world script should expose specific answers, not arbitrary evaluation.

 1// bridge-main.js — runs in the page's own context
 2import { REQUEST, REPLY } from "./bridge-protocol.js";
 3
 4const HANDLERS = {
 5  routeName: () => window.__APP__?.router?.current?.name ?? null,
 6  cartTotal: () => Number(window.__APP__?.store?.cart?.total ?? 0),
 7  // No "eval" handler. No "getGlobal(path)" handler. Enumerated answers only.
 8};
 9
10document.addEventListener(REQUEST, (event) => {
11  const { id, op } = event.detail ?? {};
12  const handler = HANDLERS[op];
13  const detail = handler
14    ? { id, ok: true, value: handler() }
15    : { id, ok: false, error: "unknown-op" };
16
17  document.dispatchEvent(new CustomEvent(REPLY, { detail }));
18});

Execution context: MAIN world — same JavaScript context as the page, so window.__APP__ is visible and chrome.* is not. The enumerated HANDLERS map is the security boundary: a generic path-reader would let anything that can dispatch the request event read any page global, and the page can dispatch that event. Behaviour is identical in Chrome, Firefox and Safari 17+, though Safari before 17 has no MAIN world at all, so probe for it and skip the feature.

Step 4. Correlate and time out on the isolated side

One bridged request, correlated by idThe isolated script dispatches a request with an id, listens for the matching reply, and rejects on a timeout so a missing MAIN script cannot hang the caller.Service workerIsolated scriptShared DOMMAIN scriptsendMessage({ type: "ROUTE" })dispatch REQUEST { id, op }listener runs in page contextdispatch REPLY { id, ok, value }matching id resolves the promiseothers ignoredvalidated value only
Without the id, two concurrent requests resolve with each other's answers.
 1// content.js — isolated world
 2import { REQUEST, REPLY, newId } from "./bridge-protocol.js";
 3
 4const pending = new Map();
 5
 6document.addEventListener(REPLY, (event) => {
 7  const { id, ok, value, error } = event.detail ?? {};
 8  const entry = pending.get(id);
 9  if (!entry) return;                       // not ours, or already timed out
10  pending.delete(id);
11  clearTimeout(entry.timer);
12  ok ? entry.resolve(value) : entry.reject(new Error(error ?? "bridge-failed"));
13});
14
15export function askPage(op, { timeoutMs = 1500 } = {}) {
16  const id = newId();
17  return new Promise((resolve, reject) => {
18    const timer = setTimeout(() => {
19      pending.delete(id);
20      reject(new Error("bridge-timeout"));   // MAIN script absent or the page navigated
21    }, timeoutMs);
22
23    pending.set(id, { resolve, reject, timer });
24    document.dispatchEvent(new CustomEvent(REQUEST, { detail: { id, op } }));
25  });
26}

Execution context: Isolated world content script. The timeout is not optional: if the MAIN script failed to inject — a restricted page, a Safari version without MAIN-world support, a navigation between injection and request — the promise would otherwise never settle and the caller would hang forever. Ignoring replies with an unknown id is what makes the bridge safe against a page that dispatches forged replies; it can still forge a reply with a guessed id, which is why the next step exists.

Step 5. Validate everything that comes back

The page can dispatch REPLY events itself. Anything crossing the bridge is user input from a hostile source.

What may cross the bridge, and what is rejectedEach kind of value the MAIN world can return, and whether the isolated side forwards it to the worker.Value from the pageCrosses the bridgeReaches the workerShort string, known keyYesAfter shape checkFinite number in rangeYesAfter range checkNested objectStructured cloneNever forwarded rawFunction or DOM nodeStripped by cloneNoReply with an unknown idIgnoredNo10 MB stringYesRejected by length
The validator is an allow-list of shapes, not a sanitiser — anything unrecognised becomes null.
 1// content.js
 2const SHAPES = {
 3  routeName: (v) => (typeof v === "string" && v.length < 200 ? v : null),
 4  cartTotal: (v) => (Number.isFinite(v) && v >= 0 && v < 1e9 ? v : null),
 5};
 6
 7export async function readPage(op) {
 8  const raw = await askPage(op).catch(() => null);
 9  const clean = SHAPES[op]?.(raw) ?? null;
10  if (clean === null) return null;
11
12  // Only validated primitives ever reach the worker.
13  await chrome.runtime.sendMessage({ type: "PAGE_STATE", op, value: clean });
14  return clean;
15}

Execution context: Isolated world. Validating at the boundary rather than at the consumer means there is exactly one place to audit. Never forward the raw event.detail object to the worker: it is structured-cloned across the process boundary and a deeply nested object from the page becomes untrusted data inside your privileged context. The same discipline applies to anything the page can influence, as set out in best practices for content script isolation in MV3.

Cross-browser variation

  • Chrome/Edge: world: "MAIN" supported since Chrome 111 for executeScript and via registerContentScripts. CustomEvent detail crosses between worlds as a structured clone, so functions and DOM nodes are stripped.
  • Firefox: MAIN world supported from Firefox 128 for scripting.executeScript. Firefox also offers wrappedJSObject and exportFunction for reaching page globals from the isolated world directly — engine-specific, so keep it behind a probe if you use it.
  • Safari: world: "MAIN" requires Safari 17. On older versions the bridge silently has no other side, which the request timeout turns into a clean failure rather than a hang.
  • All engines: the page sees every event you dispatch on document. Assume anything you send is public, and never send a token, a user identifier, or extension state.

Verification

  1. In the page console (MAIN world), run document.dispatchEvent(new CustomEvent("tabcurator:res", { detail: { id: "x", ok: true, value: "forged" } })). Nothing should happen — no pending entry has that id.
  2. Ask for an unknown op and confirm the promise rejects with unknown-op rather than hanging.
  3. Remove the MAIN injection and confirm askPage rejects with bridge-timeout after the configured delay.
  4. Fire two requests concurrently and confirm each resolves with its own answer.
  5. Return a hostile value from a handler — a 10 MB string — and confirm the shape validator rejects it before it reaches the worker.

FAQ

Can I use window.postMessage instead of CustomEvent?

You can, and it is worse here: postMessage events also arrive from iframes and other origins, so you inherit an origin check you would not otherwise need. CustomEvent on document stays within the one document.

Is a shared window property simpler than events?

The isolated world cannot see MAIN-world properties at all, so there is nothing to share. Events are the only channel.

Should the MAIN script talk to the service worker directly?

It cannot — chrome.runtime does not exist there. Everything must pass through the isolated script, which is also where validation belongs.

What if I only need to read one value once?

Then inject a MAIN-world function with executeScript and use its return value: the InjectionResult comes straight back to the worker, no bridge required. The bridge is for ongoing, two-way conversation.

Other Core APIs & Cross-Browser Data Management Resources