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:

  1. 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++.
  2. 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:

  1. The Main World accesses an element: The webpage runs const el = document.getElementById('header'). Blink finds the C++ HTMLElement node in the DOM tree. It checks the Main World's DOMDataStore, creates a V8 wrapper for that node inside the Main World's memory, and returns it.
  2. The Isolated World accesses the same element: The content script also runs const el = document.getElementById('header'). Blink finds the exact same C++ HTMLElement node. However, it checks the Isolated World's DOMDataStore, creates a completely separate V8 wrapper inside the Isolated World's memory, and returns it.
  3. 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 click or keydown) and CustomEvent dispatches 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 = 10 in the webpage modifies the Main World's V8 global object. The content script has a different V8 global object, so window.myVar will be undefined for 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.prototype in 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.