"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.fetchin the page can be a monkey-patched version;window.fetchin your content script is the pristine browser one. - Custom globals do not cross. If the page does
window.gtag = ..., your content script seesundefined. If your content script doeswindow.clipdeckHook = ..., the page sees nothing. - Class prototypes are independent.
document.querySelector('div') instanceof HTMLElementis true in both worlds, but each world has its ownHTMLElementconstructor reference.
Why It Exists
Security and stability, both directions:
- Page protection from extensions. A buggy extension that overwrote
window.fetchwould 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'schrome.runtime.sendMessagecall. 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:
- DOM events. Page dispatches a
CustomEventon a known element; content script listens. Content script dispatches; page listens. This is the most isolated and most explicit pattern. window.postMessage. Both worlds share the samewindowinstance (a DOM-bound object) and can post messages to it. Thedatapayload is structured-cloned across worlds — primitives, plain objects, arrays survive; functions and class instances do not.- 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.
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.