Chromium Content Scripts: Isolated Worlds and the Shared DOM
This article explains the architectural mechanism that allows Chromium browser extensions to run content scripts in an "isolated world" while simultaneously sharing the underlying Document Object Model (DOM) with the host webpage. By examining the interaction between Chromium's Blink rendering engine and the V8 JavaScript engine, we will explore how separate JavaScript execution contexts are maintained, how C++ DOM nodes are mapped to distinct JavaScript wrappers, and how this design prevents conflicts and security vulnerabilities between extensions and web pages.
The Concept of Isolated Worlds
When a webpage loads in Chromium, its JavaScript executes in what is known as the "Main World." When a browser extension injects a content script into that same page, Chromium creates a separate execution environment for the script called an "Isolated World."
Isolated worlds exist to solve two major problems: collisions and
security. Without isolation, a content script and a webpage might
accidentally use the same global variable names, breaking functionality.
Worse, a malicious webpage could overwrite standard JavaScript
prototypes (like Array.prototype.push) to hijack the
execution of the extension's content script, potentially gaining access
to privileged extension APIs.
Separating the C++ DOM from JavaScript
To understand how an isolated world shares the DOM without sharing JavaScript state, you must look at how Chromium separates webpage structure from script execution. Chromium uses two primary components for this:
- Blink (The Rendering Engine): Written in C++, Blink is responsible for parsing HTML and constructing the actual DOM tree in memory. The true DOM lives entirely in C++.
- V8 (The JavaScript Engine): V8 executes JavaScript code. JavaScript cannot directly manipulate C++ memory, so V8 uses "JavaScript wrappers"—objects in V8 memory that act as proxies holding pointers to the underlying C++ DOM nodes in Blink.
How the Mapping Mechanism Works
When a webpage and a content script run on the same tab, there is only one C++ DOM tree in Blink, but there are multiple V8 execution contexts (one for the Main World, and one for each extension's Isolated World).
Each V8 execution context gets its own global window
object, its own built-in object prototypes, and its own
DOMDataStore. The DOMDataStore is an internal
mapping table that links C++ DOM nodes to their corresponding V8
JavaScript wrappers for that specific world.
Here is what happens when both worlds interact with the DOM:
- The Main World accesses an element: The webpage
runs
const el = document.getElementById('header'). Blink finds the C++HTMLElementnode in the DOM tree. It checks the Main World'sDOMDataStore, creates a V8 wrapper for that node inside the Main World's memory, and returns it. - The Isolated World accesses the same element: The
content script also runs
const el = document.getElementById('header'). Blink finds the exact same C++HTMLElementnode. However, it checks the Isolated World'sDOMDataStore, creates a completely separate V8 wrapper inside the Isolated World's memory, and returns it. - Modifying the DOM: When the content script runs
el.textContent = 'New Title', its V8 wrapper forwards this instruction to the underlying C++ DOM node in Blink. Because the webpage's V8 wrapper points to that exact same C++ node, the change is immediately reflected on the webpage and visible to the Main World's JavaScript.
What is Shared vs. What is Isolated
Because of the wrapper architecture, there is a strict boundary between DOM mutations and JavaScript mutations.
Shared across worlds:
- DOM Structure and Attributes: Adding, removing, or modifying HTML elements, classes, styles, and attributes alters the C++ DOM, making it visible to all worlds.
- DOM Events: Standard events (like
clickorkeydown) andCustomEventdispatches propagate through the C++ DOM tree and can be listened to by both the webpage and the content script.
Isolated to each world:
- Global Variables: Setting
window.myVar = 10in the webpage modifies the Main World's V8 global object. The content script has a different V8 global object, sowindow.myVarwill beundefinedfor the content script. - Expando Properties: If a webpage attaches a custom
JavaScript property directly to a DOM node (e.g.,
document.body.secretData = '123'), this property is saved on the Main World's V8 wrapper, not the C++ DOM node. The content script's V8 wrapper will not have this property. - JavaScript Prototypes: Modifying
HTMLElement.prototypein the Main World only affects the prototype chain within that specific V8 context. The Isolated World retains its own clean, uncorrupted prototype chain.
Communicating Between Worlds
Because JavaScript memory is not shared, a content script and a webpage cannot invoke each other's functions directly. To pass data between the Main World and an Isolated World, developers must use the shared C++ infrastructure.
This is officially done using window.postMessage() or by
dispatching a CustomEvent on a shared DOM element. When
these methods are called, Blink serializes the data from the sender's V8
context, passes it through the C++ layer, and deserializes it into a new
JavaScript object inside the receiver's V8 context, maintaining strict
isolation while allowing controlled communication.