Skip to main content
How It Works

How Chrome Extensions Actually Run Your Code

How Chrome extensions execute code: content scripts, isolated vs main worlds, the MV3 service-worker lifecycle, and message passing — with real code examples.

The gist
  • A Chrome extension runs in three environments: an isolated-world content script, an event-driven service worker with no DOM, and — opt-in — the page's main world.
  • The DOM is shared; JavaScript is not. A content script and the page touch the same element tree but can't read each other's variables, so they exchange copies of data through message passing.
  • The MV3 service worker is ephemeral: it unloads after ~30s idle. Persist state to chrome.storage, register listeners at the top level, and use chrome.alarms instead of setTimeout.

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.

Diagram: a browser tab holds an isolated-world content script, the page's main world, and a shared DOM; a separate service worker connects to the content script through chrome.runtime messaging.
The three environments and how they connect. Shared DOM in the tab; the service worker sits outside it.

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.

Diagram: the isolated world and main world each keep private variables; a dotted line marks that JavaScript never crosses between them, while both point down to the same DOM node in memory through their own JS wrapper.
Both worlds point at the same DOM node — through their own separate JS wrapper.
A quick analogy Think of two people annotating the same printed page. They both mark up the identical sheet — that's the shared DOM. But each keeps a private notebook of thoughts the other never sees — that's the isolated JavaScript. Same page, separate notebooks.

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," so chrome.runtime and friends are undefined. To communicate, it has to fall back to window.postMessage or 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.

State diagram: Dormant to Active on an event, Active to Idle when work finishes, Idle back to Active on a new event which resets the timer, Idle to Terminated after about 30 seconds, and Terminated back to Active on the next event.
The MV3 lifecycle. Events reset the 30-second timer; the worker restarts on the next event.

That ephemerality has three hard consequences every MV3 developer has to internalize:

  1. No persistent in-memory global state. Any global variable is lost when the worker unloads. A let counter = 0 silently resets. Persist to chrome.storage (e.g. chrome.storage.local), which writes to disk.
  2. 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.
  3. Don't use setTimeout/setInterval for long delays. These timers are canceled when the worker terminates. Use the chrome.alarms API 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.

BumbleTap is built on exactly this architecture.

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.

Add to Chrome — 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.

Sequence diagram: the page posts a message to the content script via window.postMessage (validate source and origin), the content script forwards it to the service worker via chrome.runtime.sendMessage, and the worker replies with sendResponse. A note reads: copies only, JSON or structured clone, no functions or DOM nodes.
Page → content script → service worker, and back. Everything crossing a boundary is a copy.

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:

  1. 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.
  2. Treat the service worker as stateless. Assume it can die between any two events: write state to chrome.storage immediately, register all listeners at the top level, and replace timers with chrome.alarms. Test it by manually stopping the worker in chrome://serviceworker-internals. If a feature breaks, it had a hidden state dependency.
  3. Pick the injection model by predictability. Known URL set means declarative. Runtime decisions or activeTab-gated actions mean programmatic.
  4. Harden every message boundary. In any postMessage relay, validate event.source === window and check event.origin, and never use a wildcard matches for a script that forwards page messages to the worker. Send only plain, JSON-serializable data.
  5. Build MV3-native. If a tutorial shows "manifest_version": 2, persistent background pages, or chrome.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 the world field to both the content_scripts manifest key and chrome.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 with world: "MAIN", or bridge via window.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 and chrome.* API calls reset the idle timer. Don't fight it — persist state to chrome.storage, register listeners at the top level, and use chrome.alarms for delays.
Can a service worker access the DOM?
No. Service workers have no window, no document, 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 a content_scripts entry in the manifest, or call chrome.scripting.executeScript with world: "MAIN" at runtime. Remember that main-world code cannot use chrome.* APIs and must relay data to your extension via window.postMessage or 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.runtime messaging, or the structured-clone algorithm for postMessage. Functions and DOM nodes can't be sent either way; circular references throw under JSON but survive structured clone.

Shahzeb Umer

Founder, BumbleTap

Interested in a little of everything. BumbleTap is what happened when he got tired of repeating the same browser clicks and built his own fix.

More from Shahzeb

Master the keyboard-first web.

Get Keystrokes in your inbox — features, tips, news and research, about once a week. No spam, unsubscribe anytime.

One email a week · No spam · Unsubscribe anytime