C.W.K.
Stream
Lesson 03 of 06 · published

The Isolated World — Same DOM, Different JavaScript

~12 min · isolated-world, security, content-script, scripting, world-main

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Same DOM, different JavaScript. If you accept just that one sentence, every other content-script puzzle solves itself. Lesson 3 is the deep dive on what is shared, what is not, and the escape hatches Chrome provides when you really do need the page's world."

The Two-World Model

When Chrome injects a content script into a tab, it creates a new JavaScript context for it — a separate heap, a separate global object, a separate set of class prototypes. Your script's window and the page's window are different objects that happen to wrap the same underlying browser primitives.

What this means in practice:

  • The DOM is shared. Both worlds see the same document, the same elements, the same attributes.
  • JavaScript identity is not. window.fetch in the page can be a monkey-patched version; window.fetch in your content script is the pristine browser one.
  • Custom globals do not cross. If the page does window.gtag = ..., your content script sees undefined. If your content script does window.clipdeckHook = ..., the page sees nothing.
  • Class prototypes are independent. document.querySelector('div') instanceof HTMLElement is true in both worlds, but each world has its own HTMLElement constructor reference.

Why It Exists

Security and stability, both directions:

  • Page protection from extensions. A buggy extension that overwrote window.fetch would break the page. Isolation prevents that.
  • Extension protection from pages. A malicious page that defined function chrome() { … } in its own world could not redirect your extension's chrome.runtime.sendMessage call. Isolation prevents that.
  • Multi-extension safety. If two extensions both inject scripts into the same page, each gets its own world. Their globals do not collide.

This isolation is what makes content scripts a usable security primitive at all — without it, extensions and pages would be in a constant arms race.

How the Two Worlds Talk

Three legitimate bridges, in order of preference:

  1. DOM events. Page dispatches a CustomEvent on a known element; content script listens. Content script dispatches; page listens. This is the most isolated and most explicit pattern.
  2. window.postMessage. Both worlds share the same window instance (a DOM-bound object) and can post messages to it. The data payload is structured-cloned across worlds — primitives, plain objects, arrays survive; functions and class instances do not.
  3. Inject a script tag. Pre-Chrome-111 path: write a file inside your extension, list it in web_accessible_resources, create a <script src="chrome-extension://.../injected.js"> from the content script. The injected file runs in the page's world. Combine with a DOM event or postMessage to send results back.

Lesson 5 walks the page bridge in working detail. For most ClipDeck needs (read selection, read page metadata, save a clip), no bridge is needed — the content script's own world has everything required.

The world: 'MAIN' Escape Hatch

Chrome 95+ added world: 'MAIN' to chrome.scripting.executeScript; Chrome 111+ extended the same option to declarative manifest entries. With it, your script runs in the page's own JavaScript world — full access to window.gtag, window.React, whatever the page defines. The price: you lose isolation entirely, you lose the chrome.* APIs (only chrome.runtime remains), and the page can see and modify your script's globals.

Use MAIN when you genuinely need the page's world — integrating with a page-side library, monkey-patching a framework function, reading from a custom window.__store. Stay in ISOLATED for everything else.

Same DOM, different JavaScript. Isolation is the default; MAIN world is the explicit, narrow escape hatch. If you can solve the problem in ISOLATED with DOM events or postMessage, do that — MAIN world trades security for convenience.
The chrome ambiguity trap. In an ISOLATED-world content script, chrome is the extension API. In a MAIN-world content script, chrome is the page's window.chrome — a much smaller browser-defined object (mostly chrome.webstore, chrome.runtime.id for installed apps). If your MAIN script does chrome.runtime.sendMessage, that call will silently fail or throw a misleading error. Either keep extension API calls in the ISOLATED partner script, or pass results back via postMessage / DOM events.

Code

ISOLATED-world content script — DOM yes, page globals no·javascript
// content.js (ISOLATED world — the default)
// What the page defines is invisible to us, and vice versa.

window.clipdeckMarker = "hello from ClipDeck content script";

// Try to read a hypothetical page global. Almost always undefined.
console.log("[ClipDeck content] window.gtag is:", window.gtag);

// Confirm the DOM is shared.
const h1 = document.querySelector("h1");
if (h1) console.log("[ClipDeck content] first h1 text:", h1.textContent);

// Demonstrate prototype identity is per-world.
console.log("[ClipDeck content] HTMLElement is:", HTMLElement);
MAIN-world injection — read page-defined globals from the SW·javascript
// background.js — inject into MAIN world to read a page global
chrome.action.onClicked.addListener(async (tab) => {
  if (!tab.id) return;
  const [result] = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    world: "MAIN",
    func: () => {
      // We are now in the page's own JS world.
      // window.gtag (if present) is reachable here.
      return {
        hasGtag: typeof window.gtag === "function",
        hasReact: typeof window.React !== "undefined",
        documentTitle: document.title,
      };
    },
  });
  console.log("[ClipDeck SW] page-world snapshot:", result.result);
});
ISOLATED ↔ MAIN bridge via window.postMessage with a sentinel·javascript
// content.js (ISOLATED) — talk to a MAIN-world helper via window.postMessage
window.addEventListener("message", (event) => {
  // Always validate origin and a sentinel field before trusting the payload.
  if (event.source !== window) return;
  if (event.data?.source !== "clipdeck-page-script") return;

  console.log("[ClipDeck content] received from page:", event.data.payload);
});

// Send a request to the page-world script
window.postMessage(
  { source: "clipdeck-content-script", type: "giveMeReactVersion" },
  "*"
);

External links

Exercise

In clipdeck/content.js, replace the body with the first code block. Open any page that defines window.gtag (most news sites and major retailers do — try cnn.com or amazon.com), open the page's DevTools, switch the Console context to 'ClipDeck' and watch for the window.gtag is: undefined line. Now switch the Console context to 'top' and type window.gtag — it will be a function or object. Same DOM, different JS. Then add the second code block to clipdeck/background.js (you may need to temporarily remove the popup again as in Lesson 2's exercise). Click the toolbar icon — the SW console should report hasGtag: true. The MAIN-world injection reached the page's actual gtag; the ISOLATED-world content script could not.
Hint
If both contexts show gtag, you accidentally opened a Wikipedia or other gtag-free page — try a commerce or news site. If MAIN-world injection throws world is not a valid option, your Chrome is older than 95 (very unlikely on a modern install — check chrome://version). If the SW console reports hasGtag: false on a site that obviously has analytics, that site might be lazy-loading gtag in a way that hasn't fired yet by the time you click — wait a few seconds after navigation and click again.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.