Running extension code in the page's world, and coordinating across the boundary

Content scripts run in an isolated world. They share the page’s DOM but have their own JavaScript environment: their own window bindings, their own fetch, and access to extension APIs like messaging and storage. The page’s scripts can’t see them, and they can’t see the page’s scripts. That separation is the point, and for most extensions it’s all you need.

Some jobs need the other side. If the work involves the page’s own JavaScript objects — the functions it calls, the state it keeps — your code has to run in the page’s world, called the MAIN world. And the moment part of your extension lives there, you have a coordination problem: MAIN-world code has no extension APIs, and your background worker can’t reach page variables. The two halves have to talk through a channel both can reach. I built and maintained this arrangement in a Safari and Chrome ad blocker with over two million users at Pie, where I owned the iOS and Safari extension domain. The patterns below are what survived production.

What each world can see

The isolated world gets the DOM and the extension APIs — runtime messaging, storage, everything under the extension namespace. It does not get the page’s JavaScript objects. A global the app sets, a function the app patched, a framework’s in-memory state: all invisible.

The MAIN world is the page’s own context. Your script sees everything the page defines and can wrap anything the page calls. In exchange it loses the extension APIs entirely. No messaging to your background worker, no extension storage. It is a guest in someone else’s runtime, with only the tools the page itself has.

When you genuinely need MAIN

Three jobs actually require it:

  • Observing or wrapping page-level APIs.If you need to inspect a response the app requested — not a request you made — you wrap the page’s fetch. The isolated world’s fetchis a different binding; the app’s calls never pass through it.
  • Reading in-page state. Data the app exposes only as JavaScript objects, never serialized into the DOM.
  • Altering data before the app consumes it. For example, removing fields from a JSON response so the app behaves as if they were never there.

Registration matters as much as the code. These scripts run with world: “MAIN” at document_start, because a wrapper installed after the app’s first call is useless. In the system I worked on, they were registered and unregistered programmatically as the feature toggled — and when a script had to be disabled for a page that was already loaded, the tab was reloaded, because you can’t undo surgery on a response that already came back.

Ordering is a hard constraint

When two MAIN-world scripts touch the same data, registration order decides who wins. The system I maintained had a measurement script that read metadata out of a response payload and a blocking script that deleted parts of that same payload. The measurement script had to be registered first. Register it second and it reports nothing — you cannot observe a payload you already deleted.

This gets awkward the moment scripts toggle independently. If the deleting script is unregistered and later re-registered, naive re-registration can land it ahead of the observer. The fix is an unregister-and-re-register dance: tear down the affected scripts and rebuild the registrations in the order you need, every time the set changes. It feels heavy. It is also the only way to make the ordering a guarantee instead of an accident of startup sequence.

Coordinating across the boundary

The channel between worlds has to be something both sides can reach: a page-scoped storage entry, a DOM attribute, or custom events relayed by an isolated-world script.

Here is a shape that shipped. The background worker decided — from state only it had — that the MAIN-world script should skip its normal behavior on one specific page. It executed a tiny function in the tab that wrote a session-scoped flag: a page-scoped sessionStorage entry whose key included the exact page URL. The MAIN-world script checked for that flag before acting and stood down when it found it. One write, one read, no messaging required.

The URL in the key is load-bearing. sessionStorage survives soft navigations in a single-page app, so a bare flag set on one page would still be there on the next one, and the exemption would silently follow the user around. Keying the flag to the exact URL scopes it to one page view. When the app navigates internally, the new URL doesn’t match, and the script resumes normal behavior without anyone cleaning up.

You are a guest in a hostile context

MAIN-world code shares the runtime with the page, and the page got there first — or will patch things after you. Two consequences:

  • Capture native references before you patch. If your fetch wrapper reads response bodies, save your own references to the native Response methods — clone, text— at install time and call those. The production wrapper I worked on did exactly this, so later page-level patches of the same methods couldn’t interfere with or observe its reads.
  • Assume everything you leave behind is visible.Your storage keys and any globals you set live in the page’s world. Page code can enumerate them. Don’t put anything there you wouldn’t show the page.

Guard against double installation with a sentinel. Set a marker when your patch installs; bail if it’s already there. Re-registration, soft navigation, and injection races can all run your script twice, and a double-wrapped fetch means double-counted events and doubled overhead.

The DOM as the dedupe store

When your script marks up or counts things in the page, the marker can be the record. On the feature I led, a small badge injected into a container doubled as the proof that the container had been handled: before processing an element, check for the marker; if present, skip. For counting, the element’s identity came from a hash of its serialized HTML, so when the app re-rendered the same element, repeated sightings collapsed to one ID instead of inflating the count. No side table to keep in sync with a DOM that a framework rewrites at will — the DOM itself carries the state.

Platform caveats

The registration APIs are broadly shared across Chromium and Safari, but the supporting pieces are not. In the codebase I maintained, a localStorage caching layer used on Chrome was disabled on Safari because storage-quota behavior differs there. That is the general rule: the MAIN-world mechanism itself ports, but every storage, caching, and lifecycle assumption around it needs verification per platform. Test on the Safari versions you actually support rather than assuming parity from a compatibility table.

The summary I’d give another engineer: keep as much as you can in the isolated world, put only the code that truly needs page objects in MAIN, make registration order explicit, and pick one boring channel — a URL-keyed flag, an attribute, an event — for the two halves to meet. Everything else about the pattern is discipline, not cleverness.

Questions clients ask

When do I actually need a MAIN-world content script?

Only when you have to touch the page’s own JavaScript objects: wrapping fetch or XHR to see responses the app requested, reading state the app puts on window, or altering data before the app consumes it. If the job is reading or editing the DOM, stay in the isolated world — you keep extension APIs and messaging there.

How do I send a message from a MAIN-world script to my background worker?

You can’t, directly — MAIN-world code has no extension APIs. Bridge through something both sides can reach: a custom DOM event relayed by an isolated-world script, a DOM attribute, or page-scoped storage. The background worker can write into the page with scripting.executeScript, and the MAIN-world script reads what it wrote.

Why did my MAIN-world script run too late to wrap fetch?

Wrapping only works if your wrapper is installed before the page makes the call, which means registering at document_start. If you register the script programmatically after a page has already loaded, the page needs a reload — you cannot intercept a response that already came back.

Does Safari support MAIN-world content scripts?

Yes. Modern Safari supports the world: "MAIN" option for content scripts and for scripting.executeScript, and I shipped a production feature that relied on it. Auxiliary behavior still differs — for example, storage-quota-sensitive caching we used elsewhere was disabled on Safari — so verify each supporting piece per platform.

Porting an extension that lives in both worlds?

MAIN-world scripts, registration ordering, and cross-world handshakes are exactly where Safari ports break quietly. The Safari port assessment is $2,500, maps every script and channel in your extension to its Safari answer, and is credited toward follow-on work.

Book a free intro callEmail me insteadI personally reply within one business day. · Currently booking September 2026.