The bug that made all of this finally click for me was a search box on someone else's site that wouldn't stay filled. My content script set the input's .value, fired an input event, and a frame later the text just vanished — the page's own JavaScript had reverted it. Nothing threw. Nothing was broken. My code and the page's code were living in two different worlds, and I'd written to the wrong one.
Your content script and the web page share the same DOM. Neither one can read the other's JavaScript variables. That reads like a contradiction, and it's the exact thing that trips up almost everyone writing their first extension: you inject a <div>, the page renders it instantly, so you reach for the page's window.currentUser — and get back undefined.
That gap is the whole game. A Chrome extension runs your code in three separate execution environments, each trading access for safety: content scripts that live inside a web page but in an isolated JavaScript world; an event-driven service worker that runs in the background with no page and no DOM; and, when you opt in, the page's main world, where your code runs next to the site's own JavaScript. Where each piece runs, what it can touch, and how the pieces talk is the mental model everything else about extensions hangs on.
Four terms recur throughout. The DOM (Document Object Model) is the browser's live, in-memory tree of a page's elements. An execution context is a self-contained JavaScript environment with its own global scope and built-ins. V8 is Chrome's JavaScript engine, and it uses isolated instances to keep one environment's objects from leaking into another. Message passing is the asynchronous mechanism extensions use to move data between these environments, because they can't call each other's functions directly.
Three environments, three sets of powers
Each environment trades that access in a different direction. A content script can read and rewrite the page's DOM but not its JavaScript variables. A service worker can call privileged chrome.* APIs but can't touch a page. Main-world code reaches the page's own JavaScript but loses the extension APIs. None of them can read another's variables directly, so they coordinate through message passing.
A short history: Manifest V1 to V3
Every extension is described by a manifest.json file, and its manifest_version key marks which platform generation it targets. Manifest V2, launched in 2012, was the standard for a decade. Manifest V3 (MV3) reached stable Chrome on January 19, 2021, and after a long, staged rollout, MV2 was disabled for every user by July 2025. It no longer runs on stable Chrome.
MV3 introduced four headline changes: service workers replaced persistent background pages; declarativeNetRequest replaced blocking webRequest; remotely-hosted code was banned (an extension may only run JavaScript shipped inside its own package); and many APIs became promise-based. The execution model below is largely the same regardless of version, but I'm describing it in its MV3 form.
Content scripts live in an isolated world
A content script is a file your extension injects into a web page so it can read and modify that page with standard web APIs. Per Chrome's docs, content scripts "can read details of the web pages the browser visits, or make changes to them." But they do it from inside a protective bubble called an isolated world.
An isolated world is a private JavaScript environment
An isolated world is a private JavaScript execution environment for a content script, one the page (and other extensions) can't reach. In Chrome's own words, "JavaScript variables in an extension's content scripts are not visible to the host page or other extensions' content scripts."
Chrome does this for security and stability. If your script declares var greeting = "hi" and the page also declares var greeting, the two must not collide. And a malicious page must never be able to reach into your script and hijack its privileged capabilities. Isolation even protects built-ins: if a page redefines window.confirm, your content script still sees the original.
Shared DOM, isolated JavaScript — the mental model that trips people up
Here's the subtle part: the DOM is shared across worlds, but JavaScript objects and scopes are not. A content script and the page manipulate the same DOM tree. Your script can add elements, read text, and attach listeners, and the page sees every change. But the two live in separate JavaScript universes and cannot see each other's variables.
Under the hood, a single V8 isolate contains one main world and N isolated worlds. Every DOM element is a C++ object, and each world gets its own DOM wrapper: a JavaScript object pointing at the same underlying node. Both worlds reference the same paragraph element in memory, but each reaches it through its own separate wrapper. Shared DOM, isolated JS.
The main world: world: "MAIN"
Sometimes isolation is exactly what you don't want. To read a page's own globals, hook a framework's internals, or modify window in a way the page can see, you run in the main world: the execution environment shared with the host page's JavaScript.
For a long time the isolated world was all BumbleTap needed. Then I tried to type into other people's editors. Setting .value and dispatching an input event is enough for a plain <input>; on anything built with React or Draft.js — Gmail's compose box, most modern web apps — it isn't. React keeps its own shadow copy of each field's value in an internal _valueTracker on the DOM node, and if that tracker doesn't change, React decides nothing did and paints your text right back out.
The catch is that _valueTracker is a property the page's React put on the element, so from the isolated world it isn't even there. Same node, but the expando properties each world hangs on it are invisible to the other. Resetting that tracker — and falling back to a simulated paste ClipboardEvent when execCommand gives up — means running in the page's own world. So BumbleTap's text-input path is a small script injected with world: "MAIN"; the isolated content script hands it work over a scoped CustomEvent, and the main-world half does the typing where React can actually see it.
Injecting into the main world
Declaratively, the content_scripts key accepts a world property. It defaults to "ISOLATED"; set it to "MAIN":
{
"manifest_version": 3,
"content_scripts": [
{ "matches": ["https://*.example.com/*"], "js": ["isolated.js"] },
{
"matches": ["https://*.example.com/*"],
"js": ["main-world.js"],
"world": "MAIN",
"run_at": "document_start"
}
]
}
Programmatically, inject at runtime with chrome.scripting.executeScript, passing world: "MAIN":
chrome.scripting.executeScript({
target: { tabId },
world: "MAIN",
func: () => {
// Runs in the page's world; it can read the page's globals.
console.log("Framework version:", window.__FRAMEWORK_VERSION__);
}
});
What you give up in the main world
Running in the main world is powerful but risky, and the docs warn about it directly. Two concrete consequences:
- You lose the extension's protective CSP. Isolated-world scripts run under a strict Content Security Policy; in the main world, "the CSP of the page applies" instead.
- You lose the
chrome.*APIs. Main-world code is effectively page code, "not a content script… not a part of the extension anymore," sochrome.runtimeand friends are undefined. To communicate, it has to fall back towindow.postMessageor custom DOM events relayed by an isolated-world script.
Use the main world only when you genuinely need page-context access, and treat any data crossing from the page as untrusted.
The service worker: an ephemeral background brain
The third environment is the extension's background logic. In MV2 this was a persistent background page that stayed loaded for the whole session. MV3 replaced it with an extension service worker, a background script that "acts as the extension's main event handler":
{
"manifest_version": 3,
"background": { "service_worker": "service-worker.js", "type": "module" }
}
A service worker is event-driven, runs off the main thread, and has no associated page. The key behavioral difference: it's ephemeral. As the docs put it, service workers "stay dormant until an event they are listening for fires… execute the appropriate event listener, idle, then unload."
Why your service worker keeps getting terminated
An MV3 service worker is terminated after roughly 30 seconds of inactivity. That's the intended lifecycle, not a bug. Chrome shuts one down after 30 seconds of inactivity, when a single task takes longer than 5 minutes, or when a fetch() response takes over 30 seconds. Any event or chrome.* API call resets the idle timer.
This one bit BumbleTap's macro recorder. Recording captures each action as you click around a page, and "clicking around a page" includes long pauses. A service worker that holds the in-progress recording in a plain variable doesn't survive those pauses: thirty seconds of you reading the page, the worker unloads, and the steps you'd already captured are gone. No error. The recording just quietly resets, which is the nastiest kind of bug to chase, because nothing failed. The state simply wasn't there anymore.
The fix is the boring, correct one: write the recording state to chrome.storage.session on every change and restore it when the worker wakes back up. I reach for storage.session rather than storage.local on purpose. It survives the worker dying but clears when the browser closes, so a half-finished recording never leaks into next week's session.
That ephemerality has three hard consequences every MV3 developer has to internalize:
- No persistent in-memory global state. Any global variable is lost when the worker unloads. A
let counter = 0silently resets. Persist tochrome.storage(e.g.chrome.storage.local), which writes to disk. - Register event listeners synchronously, at the top level. Because Chrome restarts the worker to dispatch an event, listeners have to be registered the instant the script runs. Registering one inside a promise or callback "is not guaranteed to work in Manifest V3," and the event is missed.
- Don't use
setTimeout/setIntervalfor long delays. These timers are canceled when the worker terminates. Use thechrome.alarmsAPI instead.
And this one matters: a service worker has no DOM, no window, and no document. For work that genuinely needs the DOM (parsing HTML, playing audio), MV3 added the Offscreen Documents API. It also can't use localStorage, a window API. That's another reason state lives in chrome.storage.
Isolated-world content scripts, a MAIN-world bridge, and a service worker — so any keystroke can run an action on any site. It's free.
Message passing: how the worlds communicate
Because these environments each have their own isolated scope, they coordinate by passing messages: sending copies of data asynchronously rather than sharing objects.
Content script ↔ service worker
For a single request-and-response, use chrome.runtime.sendMessage (from a content script) or chrome.tabs.sendMessage (from the worker to a tab), and listen with chrome.runtime.onMessage.
// service-worker.js — the top-level listener is required
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "get-user-data") {
fetch("https://example.com/api/user")
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((data) => sendResponse(data))
.catch((err) => sendResponse({ error: String(err) })); // else the channel hangs
return true; // keep the channel open for the async response
}
});
That return true is not optional: it tells Chrome the listener will call sendResponse asynchronously, which keeps the message channel open. For repeated back-and-forth, open a long-lived connection with chrome.runtime.connect, which hands each side a Port.
Isolated world ↔ main world
The isolated and main worlds can't call each other's functions — but they share the DOM, and the DOM can carry messages. The standard bridge is window.postMessage: the main-world script posts a message to its own window, and the content script listens on window and relays it onward.
// content-script.js (isolated world) — relay to the service worker
window.addEventListener("message", (event) => {
if (event.source !== window) return; // only our own window
if (event.data?.type === "FROM_PAGE") {
chrome.runtime.sendMessage({ payload: event.data.text });
}
});
One security caution here: always validate event.source, and ideally event.origin too. This bridge is a documented attack surface. A wildcard matches pattern plus a naïve postMessage relay can let any web page reach your privileged service worker. Treat page-origin messages as untrusted input.
I'll be straight about how BumbleTap handles this: it never opens a window.postMessage line to the service worker in the first place. That relay is the documented soft spot. The page can post to window too, so a naïve listener hands every script on the page a pipe straight to your privileged worker. BumbleTap's worlds talk over narrower channels instead. The isolated-to-main bridge is a namespaced CustomEvent, and the code-executor world and the content script pass data through prefixed DOM attributes (data-qk-run-* / data-qk-res-*) they both agree on. Neither one is a general inbox the whole page can shout into. Source and origin checks aren't wrong, but the safest relay is the one you never expose.
What can you pass? Structured-clone limits
Messages aren't shared by reference; they're copied, and the two mechanisms differ. chrome.runtime messaging serializes to JSON, which silently drops functions and Symbols and throws on circular references. window.postMessage uses the structured-clone algorithm, which is more capable: it preserves circular references and supports Map/Set, but throws a DataCloneError on functions and DOM nodes. Under either one, a class instance survives only as a plain object, its prototype lost. Stick to plain data: objects, arrays, strings, numbers, booleans, plus Map/Set over postMessage.
Two ways to inject a content script
There are two injection models; pick by predictability.
Declarative (content_scripts) |
Programmatic (chrome.scripting) |
|
|---|---|---|
| Best when | You know the pages in advance | You decide at runtime |
| Declared in | manifest.json, statically |
Called from the service worker |
| Permissions | matches host patterns |
"scripting" + host, or activeTab |
| Timing | run_at: start / end / idle |
On demand |
| World | ISOLATED or MAIN |
ISOLATED or MAIN |
With programmatic injection, note that the injected func is "serialized, then deserialized for injection." Any bound parameters and closure are lost, so pass data only through args, and those args must be JSON-serializable.
What I'd actually do
Five rules I hold to after shipping on this architecture:
- Default to the isolated world. Build every content script there first, and escalate to
world: "MAIN"only once you've confirmed the data you need is not reachable from the DOM alone. - Treat the service worker as stateless. Assume it can die between any two events: write state to
chrome.storageimmediately, register all listeners at the top level, and replace timers withchrome.alarms. Test it by manually stopping the worker inchrome://serviceworker-internals. If a feature breaks, it had a hidden state dependency. - Pick the injection model by predictability. Known URL set means declarative. Runtime decisions or
activeTab-gated actions mean programmatic. - Harden every message boundary. In any
postMessagerelay, validateevent.source === windowand checkevent.origin, and never use a wildcardmatchesfor a script that forwards page messages to the worker. Send only plain, JSON-serializable data. - Build MV3-native. If a tutorial shows
"manifest_version": 2, persistent background pages, orchrome.tabs.executeScript, it's outdated. MV2 no longer runs on stable Chrome.
Caveats
- Lifecycle timing has shifted before. The old five-minute hard cap was removed in Chrome 110; WebSocket keep-alives arrived in Chrome 116. Confirm exact behavior against the current Chrome for Developers docs for the version you target.
world: "MAIN"started in Chromium but is now cross-browser. Firefox 128 (July 2024) added theworldfield to both thecontent_scriptsmanifest key andchrome.scripting.executeScript; earlier Firefox ignores it and runs the script in the isolated world regardless. Gate any fallback on the browser version (pre-128 Firefox), not on Firefox as a whole.- The security example is illustrative. The
postMessage-relay attack class is well documented, but audit your own extension rather than assuming any single published case matches your code.
Frequently asked questions
- Can content scripts access page variables?
- No. Content scripts run in an isolated world and cannot see the page's JavaScript variables or functions — and the page can't see theirs. They do share the DOM. To reach page globals like
window.foo, inject into the main world withworld: "MAIN", or bridge viawindow.postMessage. - What is the difference between the isolated world and the main world?
- The isolated world is a private JavaScript environment unique to your content script — separate variables, separate scope, and a protective extension CSP — but it can't see the page's JS. The main world is the page's own JavaScript environment: code there can read page globals and framework internals, but loses
chrome.*APIs and runs under the page's CSP. Both worlds share the same DOM. - Why does my service worker keep getting terminated?
- Because MV3 service workers are ephemeral by design. Chrome shuts one down after ~30 seconds of inactivity, after a single task exceeds 5 minutes, or when a
fetch()takes over 30 seconds. Events andchrome.*API calls reset the idle timer. Don't fight it — persist state tochrome.storage, register listeners at the top level, and usechrome.alarmsfor delays. - Can a service worker access the DOM?
- No. Service workers have no
window, nodocument, and no DOM, and they run off the main thread. For DOM-dependent work, use a content script (which has the page's DOM) or the Offscreen Documents API. - How do I inject a script into the page's main world?
- Two ways: set
"world": "MAIN"on acontent_scriptsentry in the manifest, or callchrome.scripting.executeScriptwithworld: "MAIN"at runtime. Remember that main-world code cannot usechrome.*APIs and must relay data to your extension viawindow.postMessageor custom DOM events. - Do I have to use message passing, or can the worlds share objects?
- You must use message passing. The environments have separate JavaScript scopes, so they exchange copies of data — JSON serialization for
chrome.runtimemessaging, or the structured-clone algorithm forpostMessage. Functions and DOM nodes can't be sent either way; circular references throw under JSON but survive structured clone.


