# Your Code on Someone Else’s Page

> How embeddable widgets like Intercom, Stripe and Calendly load, isolate themselves, talk across frames and ship safely to pages their authors never see.

- Author: Vasyl Semiliak (https://www.vasylsemiliak.com)
- Published: 2026-10-23
- URL: https://www.vasylsemiliak.com/projects/your-code-on-someone-elses-page
- Tags: architecture, javascript, third-party, iframes, postmessage, security, performance

_The [web version](https://www.vasylsemiliak.com/projects/your-code-on-someone-elses-page) of this article has an interactive demo here._

On December 1, 2009, Google changed the snippet that millions of sites used to load Analytics. The old one blocked the page while `ga.js` downloaded. The new one created a script tag, marked it async, and gave the page an array to talk to in the meantime:

```js title="Google Analytics async snippet, 2009"
var _gaq = _gaq || [];
_gaq.push(["_setAccount", "UA-XXXXX-X"]);
_gaq.push(["_trackPageview"]);
```

Steve Souders [wrote it up the same day](https://stevesouders.com/blog/2009/12/01/google-analytics-goes-async/). He listed faster pages, better uptime and more data, and the second reason is the one that stuck:

> Because scripts block rendering, users are left staring at an empty page.
>
> — [Steve Souders, 2009](https://stevesouders.com/blog/2009/12/01/google-analytics-goes-async/)

Six months later he gave the problem a name, the [frontend single point of failure](https://www.stevesouders.com/blog/2010/06/01/frontend-spof/), and pointed at the usual suspects:

> Even more worrisome – many of these scripts are from third parties for social widgets, web analytics, and ads.
>
> — [Steve Souders, 2010](https://www.stevesouders.com/blog/2010/06/01/frontend-spof/)

That array is still at the heart of every embed snippet I know. Intercom, Zendesk, Cal.com and Google Tag Manager all ship a version of it. What changed is everything around it. A widget in 2026 has to survive strict Content Security Policies, partitioned storage, single-page apps that rebuild the DOM every few seconds, ad blockers that block it by name, and customers who put it on their checkout page.

I first built one of these at the very start of my career, at AXSYS, and I had to build it twice. The first version tried to coexist with the host page through heavy style overrides and home-made isolation. Host pages kept breaking it anyway, most often by monkey-patching built-ins like `Array.prototype`. Then I found [Third-Party JavaScript](https://www.manning.com/books/third-party-javascript) by Ben Vinegar and Anton Kovalyov, and the second version did it the book’s way. In early 2021 I built another one as a freelance project, a booking modal that other sites embed with one script tag, and that time I started from the right approach. It worked, people booked through it, and most of what I’d change about it is somewhere in this post. In October 2026 I went back and read the live loaders of Intercom, Stripe, Zendesk, Calendly, Cal.com and a few others, to see how the people who do this at scale handle the same problems. Minified loaders change often, so treat those details as a snapshot.

## The snippet you can never change

The snippet a customer pastes is the only code you ship exactly once. Once it’s on ten thousand sites, a new version of it reaches nobody. So a good snippet does almost nothing. It defines a stub, adds one async script tag and gets out of the way.

The stub is the `_gaq` idea. Host code wants to call your API whenever it likes, usually before your script has arrived. Intercom’s [install snippet](https://developers.intercom.com/installing-intercom/web/installation) handles that with a function that only records its calls:

```js title="Intercom stub"
var i = function () {
  i.c(arguments);
};
i.q = [];
i.c = function (args) {
  i.q.push(args);
};
window.Intercom = i;
```

The host can call `Intercom("boot", settings)` straight away. When the real bundle starts, it reads `i.q`, replays every call in order and puts the real function in its place. The stub is inline and synchronous, so it can’t fail to load and it blocks nothing.

Two things go wrong with stubs in practice. The first is running twice. Customers paste the snippet into a layout and into a tag manager, or a framework renders it on every route. Cal.com’s [snippet](https://github.com/calcom/cal.com/blob/main/packages/embeds/embed-snippet/src/index.ts) keeps any queue it already finds, and the comment above that line says why:

> Make sure that even after re-execution of the snippet, the namespace is not overridden
>
> — [Cal.com embed snippet](https://github.com/calcom/cal.com/blob/main/packages/embeds/embed-snippet/src/index.ts)

Zendesk exits early on a `zEACLoaded` flag instead.

The second is the bundle never arriving. Ad blockers, corporate proxies and a bad day at your CDN all end the same way, with the stub as the only part of you on the page. So the stub has to keep accepting calls forever, and the customer’s code must never wait on your widget to do its own job.

## The loader is the release button

Behind the snippet, almost every vendor uses the same split. A small loader lives at a stable URL with a short cache lifetime, and it loads bundles with a content hash in their file names, cached for a year. These are the `Cache-Control` lifetimes the loaders sent in October 2026:

| Loader | Cache lifetime |
| --- | ---: |
| Stripe.js, `js.stripe.com/v3/` | 2 minutes |
| Intercom, `widget.intercom.io/widget/<id>` | 5 minutes |
| Calendly, `widget.js` | 5 minutes |
| Zendesk, `snippet.js` | 1 hour |

That number is how fast you can ship. A fix or a rollback reaches every customer in 2 minutes at Stripe and 5 at Intercom, and nobody edits their HTML. The hashed bundles never change, so the browser can keep them for as long as it likes.

The loader is also where breaking changes are managed. Stripe calls its model [evergreen](https://docs.stripe.com/sdks/stripejs-versioning): fixes and features are backported to every version, and only breaking changes get a new named version at a new URL. I think that’s the right default. Customers get every fix without doing anything, and they only have to act when you actually broke something.

And it’s where trust lives. [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity) lets a page refuse a script whose hash doesn’t match, but a customer can’t pin a hash on a file that changes with every deploy. Intercom moves the check one level down. Its loader adds hard-coded `sha384` hashes to the bundles it loads, so the customer trusts a 4&nbsp;KB file and the loader checks everything else.

My 2021 version had the split, a tiny inline snippet that injected a loader, which created the iframe. It didn’t have a queue or a public API. The only way for the host to open the modal was a click on a button with the right data attribute.

## Assume the page is hostile

Inside your own frame you’re safe. On the host page, you share one document, one set of globals and one cascade with code you’ve never seen. Vendors deal with that in one of four ways:

| Approach | Isolates | Costs |
| --- | --- | --- |
| Cross-origin iframe | Styles, scripts and storage | Every interaction is a message |
| Same-origin blank iframe | Styles and globals | Shares the host’s origin |
| Shadow DOM | Host selectors | Inherited styles and globals leak |
| Plain DOM with prefixes | Very little | Breaks in both directions |

Stripe Elements, Calendly, Typeform, Plaid Link and PayPal’s buttons are cross-origin iframes. Cal.com puts its iframes inside custom elements with Shadow DOM. Intercom does something cleverer, which I’ll get to.

Plain DOM is where the support tickets come from. One broad host rule, like `button { text-transform: uppercase }` or `html { font-size: 10px }`, restyles everything you built in `rem`. Olark’s help page on [CSS conflicts](https://www.olark.com/help/css-conflicts) tells customers to load the widget on a blank page first, which tells you how often “the chat looks broken” turns out to be the host. Shadow DOM blocks the selectors but not inheritance, so `font`, `color` and `line-height` still arrive from outside, and you still reset them on the shadow root.

The globals are worse, because they lie. Prototype.js adds `Array.prototype.toJSON`, so `JSON.stringify([1, 2])` returns a quoted string, and it has broken payloads in [Jenkins](https://issues.jenkins.io/browse/JENKINS-25152) and in Sentry’s own events. MooTools added a non-standard `Array.prototype.flatten` that so many sites relied on that TC39 had to name the real method `flat` ([SmooshGate](https://developer.chrome.com/blog/smooshgate)). Angular’s Zone.js wraps `setTimeout`, `Promise` and `fetch`, and some ad blockers wrap `fetch` too.

Intercom’s answer is to never run in the host’s globals at all. Its loader creates a hidden iframe with no `src`, writes an empty document into it and appends its bundles there. The frame is same-origin, so the code can still reach the host page when it needs to, but every built-in it touches is fresh. Sentry takes a lighter approach in [`getNativeImplementation`](https://github.com/getsentry/sentry-javascript/blob/develop/packages/browser-utils/src/getNativeImplementation.ts). It creates a temporary iframe, copies the clean `fetch` or `setTimeout` out of it and throws the frame away. The source comment says why:

> `fetch`: This can be wrapped by e.g. ad-blockers, causing an infinite loop when a request is blocked.
>
> — [Sentry](https://github.com/getsentry/sentry-javascript/blob/develop/packages/browser-utils/src/getNativeImplementation.ts)

Your own code owes the host the same courtesy. Expose one global. Never patch a built-in, and import polyfills as functions instead of installing them. Prefix every class, data attribute and storage key, the way Intercom uses `intercom-`, Calendly `calendly-` and Typeform `data-tf-`.

## Every message is from a stranger

Once the app lives in a cross-origin iframe, `postMessage` is the only way across. The host side opens and sizes the frame and passes configuration in. The frame sends events back out: loaded, opened, closed, booked. That channel is the widget’s real API, and it’s where widgets most often get hacked.

The first problem is timing. The parent can’t send anything until the frame is ready, and it doesn’t know when that will be. Cal.com [documents its handshake](https://github.com/calcom/cal.com/blob/main/packages/embeds/embed-handshake.mermaid) openly. The parent creates a hidden iframe and buffers commands, the iframe announces `__iframeReady`, the parent flushes the buffer, and the iframe confirms with `linkReady`. It’s the `_gaq` queue again, one level down.

The second is trust. A `message` listener hears from every window that holds a reference to yours, and `postMessage(data, "*")` delivers to whatever document is in the frame right now, including one an attacker navigated there. In 2016 Detectify found that the AddThis share widget accepted messages from any HTTP or HTTPS origin, and one message type set a script’s `src`. That was [DOM XSS on more than a million sites](https://labs.detectify.com/writeups/postmessage-xss-on-a-million-sites/). In August 2025 Microsoft published [“postMessaged and Compromised”](https://www.microsoft.com/en-us/msrc/blog/2025/08/postmessaged-and-compromised/), with auth tokens posted to `"*"` in Bing Travel and a zero-click XSS in Teams and Copilot Studio (CVE-2024-49038) that came from trusting messages from wildcard domains.

My 2021 widget had a milder version of this. The host side checked the iframe’s origin, but the iframe accepted its parent from `"*"`, so any page that framed it could have talked to it as if it were the host. Nothing bad came of it, and several popular embeds still post to `"*"` today for the same reason: nothing sensitive goes over the channel. Yet. The day someone adds a token to it, that habit is a vulnerability.

Stripe’s handler is the one to copy. Every message carries a `channel` id, the handler drops anything meant for another channel, and it compares `event.origin` with the exact origin of the frame it created. A mismatch goes to telemetry, so Stripe sees spoofing attempts instead of quietly ignoring them.

The shape I’d use now:

- A versioned envelope, `{ ns, v, id, type, payload }`, so your messages never mix with the other five widgets on the page.
- An exact `targetOrigin` on every send, and exact `event.origin` and `event.source` checks on every receive. Compare full origins with `===`, because a substring check lets `trusted.com.attacker.com` through.
- A handshake, then a `MessageChannel` port, so later traffic skips the window’s `message` event entirely.
- Requests and responses paired by id, with timeouts, so a call to a frame that crashed doesn’t hang forever.

You don’t have to write it yourself. [Penpal](https://github.com/Aaronius/penpal) gives you promise-based calls with an `allowedOrigins` list and port support, though it has no timeout by default, so set one. In 2021 I used [post-me](https://github.com/alesgenova/post-me) for typed calls over a handshake, with the message types in a package both sides imported. That part I’d keep. A renamed event failed at compile time, not on a customer’s site.

## The page keeps moving

Most widget bugs customers report aren’t crashes. The widget works on the vendor’s test page, then meets a site that renders, navigates and restyles itself in ways nobody planned for.

My 2021 loader found every element with the trigger attribute when it started and added a click listener to each. That’s fine on a static marketing site. On a React or Next.js site, buttons show up after hydration, after a route change or when a list renders, and none of them do anything. The fix is to stop binding to elements and find the target when the click happens:

```js title="Delegated click listener"
document.addEventListener("click", (event) => {
  const trigger = event.target.closest("[data-booking-open]");
  if (!trigger) {
    return;
  }
  event.preventDefault();
  Booking("open", { product: trigger.dataset.bookingOpen });
});
```

Custom elements solve the same problem from the other side. `<stripe-pricing-table>` and `<cal-inline>` upgrade whenever they appear in the DOM, even if they were there before the script loaded, and they work in every framework and every no-code builder.

Single-page apps also never reload, so a widget that reads the URL or the user once at startup is wrong after the first navigation. Intercom asks customers to call `update` on every route change and `shutdown` on logout, and its loader also listens for Turbo and Turbolinks events on its own. It shuts down on `turbo:visit`, removes its frame before the page is cached, boots again on `turbo:load` and skips Turbo’s previews. Frameworks make double calls normal too. React runs effects twice in Strict Mode, so `boot`, `update` and `shutdown` each need to be safe to call twice.

Then there’s the state you borrow. A modal usually locks the host page’s scroll, and the quick version sets `document.body.style.overflow` to `hidden` on open and `auto` on close. Mine did exactly that. The close step throws away whatever the host had set, and some sites set it on `html`, not `body`. Save the value you found and put back exactly that, for every style, class and attribute you touch on the host.

Stacking is the other fight. Host pages are full of `z-index: 2147483647` headers, cookie banners and `overflow: hidden` wrappers. The browser’s [top layer](https://developer.chrome.com/blog/what-is-the-top-layer) ends it. A `<dialog>` opened with `showModal()` sits above every `z-index` on the page, and it comes with focus trapping, Escape to close and an inert background. For an iframe widget, the thing in the top layer has to be the host-side wrapper around the frame, because a dialog inside the iframe only reaches the top of the iframe.

Some site builders go one step further and run the customer’s embed code inside a sandboxed iframe of their own. Stripe documents that its pricing table [can’t redirect to checkout](https://docs.stripe.com/payments/checkout/pricing-table) when that sandbox lacks `allow-top-navigation`. Test in one of those builders before a customer does.

## You’re in their threat model

When a customer adds your snippet, your CDN, your build pipeline and your npm dependencies all get to run code on their site. Some of the largest web breaches came in exactly that way:

| What happened | When |
| --- | ---: |
| A phished maintainer published about 18 npm packages, [including chalk and debug](https://www.stepsecurity.io/blog/20-popular-npm-packages-compromised-chalk-debug-strip-ansi-color-convert-wrap-ansi), that hooked `fetch` and crypto wallet calls in the browser | Sep 2025 |
| [polyfill.io’s new owner](https://www.bleepingcomputer.com/news/security/polyfillio-javascript-supply-chain-attack-impacts-over-100k-sites/) served malware to more than 100,000 sites that still loaded the script | Jun 2024 |
| 22 lines added to a Modernizr file on [British Airways’ site](https://www.theregister.com/2020/10/16/british_airways_ico_fine_20m/) skimmed card data, and the fine was £20M | Jun–Sep 2018 |
| A third-party chatbot script on [Ticketmaster UK’s payment page](https://www.hunton.com/privacy-and-cybersecurity-law-blog/ico-fines-ticketmaster-1-25-million-for-security-failures) was modified to skim cards, and the fine was £1.25M | Feb–Jun 2018 |

Ticketmaster is the one I’d remember if I sold chat widgets. The vendor said its code “had not been intended” for payment pages. A customer put it there anyway, and the fine went to the customer.

Most of the defense is about working inside the customer’s rules instead of asking them to loosen them. Serious sites send a [strict Content Security Policy](https://web.dev/articles/strict-csp) along the lines of `script-src 'nonce-{random}' 'strict-dynamic'`. Under `strict-dynamic`, a script the page trusts can create other scripts, so your loader works as long as it inserts scripts with `createElement`, never `document.write` or `innerHTML`, and copies `document.currentScript.nonce` onto any script or style it creates. Then publish the exact domains for each directive. [Intercom](https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy) and [Stripe](https://docs.stripe.com/security/guide) both keep that page current.

[Trusted Types](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) became Baseline in February 2026, so expect more hosts that send `require-trusted-types-for 'script'`. On those pages, every string you assign to `innerHTML` or `script.src` throws a `TypeError`. Create a named policy and document its name, so the host can allow it. Stripe documents an exact default policy for Stripe.js.

Payment pages have their own rules now. Since March 31, 2025, PCI DSS 4.0.1 has asked merchants to authorize every script on a payment page, keep an inventory with a reason for each, and detect unauthorized changes to them. Those are requirements 6.4.3 and 11.6.1. The PCI Council later [dropped both from SAQ A](https://blog.pcisecuritystandards.org/important-updates-announced-for-merchants-validating-to-self-assessment-questionnaire-a), and made merchants confirm their site isn’t open to script attacks instead. If your widget can end up on a checkout, someone will ask for your script inventory, your change process and a stable list of domains. Keep card data in a frame on your own origin, the way Stripe Elements do, so it never touches the host’s DOM.

Two smaller ones. Your iframe pages should let customer sites frame them and nobody else. Modern browsers ignore `X-Frame-Options: ALLOW-FROM`, so send `frame-ancestors` with that customer’s domains. And the ID in your snippet is public, because it’s in the page source, so it can’t prove who the user is. Intercom’s [identity verification](https://developers.intercom.com/installing-intercom/web/identity-verification) has the customer’s server sign the user as a JWT, with a secret that never reaches the browser.

## Fifty sites, fifty strangers

In October 2025 Chrome [decided to keep third-party cookies](https://privacysandbox.google.com/blog/update-on-plans-for-privacy-sandbox-technologies). That helps widgets less than it sounds. Safari has [blocked them since 2020](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/), Firefox isolates them per site, and since Chrome 115, [storage partitioning](https://privacysandbox.google.com/cookies/storage-partitioning) keys an iframe’s localStorage and IndexedDB by the top-level site. A widget frame on 50 sites has 50 separate storages, and your returning user is a stranger on each one.

What’s left is narrower and more honest. [CHIPS](https://privacysandbox.google.com/cookies/chips), the `Partitioned` cookie attribute, gives you one cookie jar per host site, and it works in Chrome, Firefox and Safari. The [Storage Access API](https://developer.mozilla.org/en-US/docs/Web/API/Storage_Access_API) lets a frame ask for its unpartitioned cookies after a user gesture, which is worth it for a real cross-site login and nothing else. Intercom sidesteps the whole thing by running in its same-origin frame, so its state is first-party storage on the customer’s domain. The cost is the trade from earlier: its code shares the customer’s origin.

## You’re spending someone else’s budget

Your widget’s weight shows up in your customer’s Core Web Vitals, not yours. The [2024 Web Almanac](https://almanac.httparchive.org/en/2024/third-parties) found third parties on 92% of pages, with chat among the most common categories. A customer who finds you in their Lighthouse report either removes you or puts you behind a consent banner.

The range between vendors is huge. These are the compressed transfer sizes in October 2026:

| Script | Size | What loads |
| --- | ---: | --- |
| Stripe.js | ~302&nbsp;KB | One file, plus frames |
| Intercom | ~268&nbsp;KB | The whole Messenger |
| Cal.com `embed.js` | ~23&nbsp;KB | A loader, app in an iframe |
| Calendly `widget.js` | ~4&nbsp;KB | A loader, app in an iframe |

The small ones have the order right. The host page pays for a loader, and the app loads into its own frame when somebody needs it.

Most visitors never open the chat or the booking modal. My 2021 widget loaded its whole app into a zero-by-zero iframe on every page view, so everyone paid for the few who clicked. The better pattern is a facade: a static element that looks like the launcher and holds no app. On hover or focus, `preconnect` and start fetching the bundle. On click, create the frame and swap it in. Intercom [cut its Messenger](https://www.intercom.com/blog/reducing-intercom-messenger-bundle-size/) from nearly 600&nbsp;KB to 240&nbsp;KB gzipped in 2019, and it preloads the bundle when the pointer is over the launcher.

Ship that facade yourself, because the tooling stopped asking for it. [Lighthouse 13](https://developer.chrome.com/blog/lighthouse-13-0) removed the third-party facades audit in October 2025, and Next.js lists “chat support plugins” as the example for [`lazyOnload`](https://nextjs.org/docs/app/api-reference/components/script), which tells you where frameworks rank you. A small runtime helps too. React and React DOM 19 come to about 58&nbsp;KB gzipped, against about 6&nbsp;KB for Preact with hooks.

And don’t trust lab scores. At least one popular chat loader skips loading when the user agent contains `Lighthouse`, so the audit never sees it. The [Long Animation Frames API](https://developer.chrome.com/docs/web-platform/long-animation-frames) attributes long frames to a script’s URL on real pages, and that’s the number that matters.

## Shipping to pages you can’t see

A normal web app has one production. A widget has one per customer and you see none of them, so most of operations is getting signal back and being able to act fast.

Start with errors. If your loader calls `Sentry.init()` on the host page, it installs global handlers and you receive every error the host throws. If the host runs its own Sentry, the two fight over the same globals. Sentry’s guide for [shared environments](https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/) is direct about it:

> When setting up Sentry in a shared environment where multiple Sentry instances may run, you should not use `Sentry.init()`, as this will pollute the global state.
>
> — [Sentry docs, shared environments](https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/)

Create a separate `BrowserClient` with its own `Scope`, turn off the integrations that hook globals, and capture errors at your own boundaries. Inside a cross-origin iframe the problem mostly goes away, which is one thing my 2021 version got right: its Sentry only ever saw its own errors.

Then plan to turn yourself off. When a Sentry Loader key is disabled, its URL serves a no-op `Sentry` object with the full API, and each method logs a warning. Code that calls `Sentry.captureException` keeps working. That’s what a kill switch should look like: a stub that accepts every public call and does nothing, never a 404 or an object with missing methods.

With one evergreen URL, every deploy reaches every customer at once unless the loader decides otherwise, so the rollout logic belongs there. Zendesk loads a specific build when it sees `?__zE_ac_version=` in the URL or a key in localStorage, so support can pin a version on a live customer page and reproduce a bug. Intercom serves a modern build to Chrome 120 and later and a legacy one to everyone else. A percentage rollout by customer, a support override and a fast rollback cover most of it.

Everything a customer can call or set is a public API that lives for years: the global, its commands, the data attributes and the events. Zendesk still answers to its legacy name, `zEmbed`. Keep the surface small, version it from the first day and keep old names working. Most hosts are React or Vue apps, so give them an npm package, but keep it thin. `@stripe/stripe-js` only inserts a script tag for `js.stripe.com`, and its README says why:

> To be PCI compliant, you must load Stripe.js directly from `https://js.stripe.com`. You cannot include it in a bundle or host it yourself.
>
> — [Stripe.js README](https://github.com/stripe/stripe-js)

The runtime stays on your CDN, and the evergreen model keeps working.

Expect to be blocked. The opt-in [Fanboy’s Anti-Chat List](https://github.com/easylist/easylist/blob/master/fanboy-addon/fanboy_chatapps_third-party.txt) for EasyList blocks `widget.intercom.io`, `crisp.chat`, `tawk.to` and `freshchat.com` by name, and hides `.intercom-launcher`. In 2021 [Sendbird’s websocket domain](https://community.sendbird.com/t/ad-blocker-issues-in-using-js-sdk-resolved/2383) landed on EasyList, and chat broke for every uBlock user of every app built on it.

And test on pages as hostile as real ones. The vendor’s demo page is the friendliest host a widget will ever meet. I’d keep fixture pages with Prototype.js and MooTools loaded, an Angular app with Zone.js, `* { box-sizing: content-box }` and `html { font-size: 10px }`, a huge `z-index` header, a strict CSP with nonces and Trusted Types, the snippet inside a sandboxed iframe, a Next.js app whose buttons render late, right-to-left text and an ad blocker. Then run the end-to-end tests against all of them on every loader deploy.

## What I’d tell myself in 2021

**Write the snippet as if you’ll never see it again.** A stub, a queue, one async script tag and a guard against running twice. Everything else goes in the loader.

**Make the loader your release process.** A short cache, hashed bundles, integrity on what it loads, a build override for support and a kill switch that leaves a working stub behind.

**Isolate first, then talk carefully.** Run the app in a cross-origin frame, and treat every message as if it came from a stranger: exact origins both ways, a handshake, a port and timeouts.

**Assume the page will change under you.** Delegate clicks, make every lifecycle call safe to repeat, and put back every bit of host state you touch.

**Work inside the customer’s rules.** Strict CSP, Trusted Types, partitioned storage and PCI are their constraints, and supporting them is part of the product.

**Load on intent.** A facade costs a few kilobytes. The full app costs everyone who never opens it.

Google’s `_gaq` array was three lines of JavaScript, and it’s still the best idea in this space: give the page something harmless to talk to, and do the real work when you can. Almost everything since is that idea pushed further. The page was never yours, and the widgets that last are written by people who remember that.
