cf.completefrontend
My progress
SYSTEM DESIGN · #69

Design an embeddable third-party widget

A widget other companies add to their sites with one script tag, like a support chat bubble or a payment form. The hard parts are a tiny loader that never blocks the host page, isolating our CSS and JavaScript from a page we don't control (and the page from us), a safe message protocol across the iframe boundary, versioned rollouts with a kill switch, a small performance budget, blocked third-party cookies, and accessibility across frames.

The question

We sell a customer support product. Our customers paste one script tag into their websites, and a chat bubble appears in the corner. Visitors click it, a panel opens, and they can read help articles and chat with the customer's support team.

Design the frontend of this widget. It will run on about 20,000 websites we don't control: tiny shops, news sites full of ads, and large apps built with React, Angular, or 15-year-old jQuery. It must look and work the same everywhere, and it must never break or slow down the page it lives on.

Real-life examples: Intercom and Zendesk support chat bubbles, Stripe Elements card fields and Stripe Checkout, YouTube and X (Twitter) embeds, Calendly scheduling embeds, Disqus comments, Cookie consent banners

Requirements exploration

Most frontend problems assume we own the page. Here we don't. Our code is a guest on someone else's website, called the host page.

The company that owns that page is our customer, and the person using it is the visitor. We control our script, our servers, and whatever runs inside our own frames. We control nothing else.

That one fact changes every decision. The host page can have any CSS, any JavaScript, any security policy, and any bug. And because the same script runs on 20,000 sites at once, one bad release breaks 20,000 sites in the same minute. So the first minutes of the interview go to finding out what kind of guest we are.

Questions to ask first

  • What does the widget do, and how big is it? A cookie banner is a few KB of UI. A chat panel with articles, file uploads, and live messages is a real app of 150 to 250 KB. Bigger apps push us toward an iframe and lazy loading.
  • Does it handle sensitive data? Card numbers, passwords, or private messages mean the host page must never be able to read them. That rules out putting the sensitive parts in the host page's DOM at all.
  • Floating or inline? A chat bubble floats in a corner over the page. A YouTube embed or a Calendly scheduler sits inline in the page's layout, so it takes space and can cause layout shift.
  • Who installs it? If non-developers paste the snippet into a site builder, configuration must live in our dashboard, not in code. If developers install it, they also want a JavaScript API (open the panel, identify the logged-in user, listen for events).
  • What do customers customize? Colors, position, language, greeting text, which pages show the bubble. This decides the theming API.
  • How many sites and visitors? We assume 20,000 customer sites and about 1.5 billion host page views a month, which is roughly 580 loader requests per second on average and a few thousand at peak. Only about 2% of page views ever open the panel.
  • Which browsers and which strict hosts? Banks and large stores run a strict Content Security Policy (CSP). Safari and Firefox block or partition third-party cookies. These change how we load and where we keep state.

Functional requirements

  • A customer installs the widget with one script tag and an id. No build step, no framework, no npm.
  • A launcher (the round chat bubble) appears in a corner. Clicking it opens a panel with help articles and a chat.
  • The look and behavior come from the customer's dashboard settings: brand color, position, greeting, language, and page rules.
  • A small public JavaScript API for developers: Acme("boot", { userId, userHash }), Acme("open"), Acme("on", "message", fn). Calls made before the script finishes loading must still work.
  • An unread badge on the launcher and an announcement for screen reader users when a new reply arrives while the panel is closed.

Non-functional requirements

RequirementTargetWhy
Never block the hostLoader is async and under 3 KB gzipped; under 15 ms of host main-thread work at load, no task over 10 msCustomers measure their own Core Web Vitals. We must not show up in their reports.
Bytes before interactionUnder 20 KB until the visitor clicks98% of visitors never open the panel. They shouldn't pay for a 200 KB app.
No layout shiftCumulative Layout Shift (CLS) added by us: 0A floating launcher must not push content. Inline embeds must reserve their space.
IsolationHost CSS and JavaScript can't break us; we can't break the hostA broken widget on a store's checkout page loses the customer real money.
SecurityHost scripts can never read visitor tokens or messagesThe host page might be compromised. We are responsible for our visitors' data.
Safe releasesRoll out by percentage, roll back everywhere in under 5 minutesOne bad release would otherwise break every customer at once.
AccessibilityWCAG 2.2 AA inside our UI, across the iframe boundaryKeyboard and screen reader users must be able to reach, use, and leave the panel.

Out of scope

The chat backend (message storage, routing to agents, the agent inbox) is a separate design; we treat it as an API. The real-time transport inside the panel is covered by the chat widget problem. Product analytics for the widget follow the analytics SDK guide. We focus on what is special here: being a guest on a hostile page.

Check your understanding · Exercise 1

Which answers from the customer would change the architecture of the widget, not just its features? Select all that apply.

What makes a guest widget hard

Picture the simplest version: a script that creates a div with a round button, appends it to document.body, and adds a stylesheet with .bubble { ... }. It works on our test page. Then it meets real host pages.

  • A store's theme has button { background: #000; padding: 20px; text-transform: uppercase }. Our bubble turns into a black box with shouting text.
  • A news site sets html { font-size: 62.5% } so that 1rem is 10 px. Every rem size in our panel shrinks by more than a third.
  • The same news site has an ad overlay at z-index: 2147483647, the largest value browsers accept. Our panel opens behind it.
  • An old site loads a library that adds methods to Array.prototype, and another one replaces window.fetch with a wrapper that adds a header and drops our request body.
  • Our .bubble class name matches a class the host already uses. Now our styles break their newsletter popup, and they file a support ticket against us.
  • A bank sends Content-Security-Policy: script-src 'nonce-r4nd0m'. Our loader injects a script without that nonce and the browser blocks it. The bubble never shows, silently.

None of these pages is unusual: each is an ordinary site doing something ordinary. Together they describe the five forces that drive this design.

The five forces

  1. Hostile environment, both ways. The page can break us (CSS, globals, CSP), and we can break the page (leaked styles, globals, slow scripts). Isolation must work in both directions.
  2. Mutual distrust. The customer trusts our script enough to run it with full access to their page. We must not trust the page: it may be compromised, and it must never see a visitor's token or private messages.
  3. Shared fate. One script URL serves 20,000 sites. A bug ships to all of them at once, and many customers never update a snippet they pasted three years ago. Versioning and rollback are core features, not process.
  4. Someone else's budget. The host's Core Web Vitals pay for our bytes and our main-thread time. Most visitors never use us, so we must be nearly free until they do.
  5. A boundary in the middle. Once part of the widget lives in an iframe, everything crosses a frame boundary: messages, focus, sizing, screen reader announcements. Each needs a deliberate design.
Wireframe of a customer's blog post with our widget on it. The host page header and the article belong to the customer. In the bottom right corner sits our launcher, a round button with an unread badge of 2, drawn inside a shadow root. Above it, the open panel is a dashed iframe box from our own origin, with a header for Example Shop, a list of messages, and a composer at the bottom. A note at the bottom left says the loader is one async script of about 2.5 KB.shop.example.com/blog/posthost header and navhost articlenot ours: any CSS, any JSpanel iframeExample Shopmessagescomposerframe.acme.io2launcher in a shadow rootloader: async, 2.5 KB
Only the launcher and the panel are ours. Everything else on the page, including its CSS and its scripts, belongs to the customer, and our pieces must look the same whatever it does.

The wireframe shows the pieces we'll keep naming. The loader is the small script the customer pastes, and the launcher is the bubble with its unread badge. The panel is the full app: home, articles, conversation, composer. The host page is everything around them, and none of it is ours.

Think first

Before reading on: the launcher and the panel have very different needs. Which one needs to be tiny, and which one needs strong isolation? Could they use different techniques?

The one-tag loader

The customer's whole integration is one tag. Everything we do on their page starts from it, so it has three jobs: never block the page, accept calls before we are ready, and hand off to the real code as fast as possible.

The naive install is a plain script tag with src pointing at our full 200 KB bundle, placed in the head without async. A classic script like that is parser blocking: the browser stops building the page until our file downloads and runs. On a 4G phone with 150 ms of latency, that is easily half a second added to the host's Largest Contentful Paint (LCP), for a widget most visitors never touch. If our CDN has an outage, the host page hangs until the request times out.

Decision How should the customer install the widget?

Plain async script tag with data attributes

The snippet is script async src=".../loader.js" data-app-id="app_7Kq2".

The loader reads its own tag through document.currentScript and starts.

Pros

  • Shortest snippet, easy to paste into site builders.
  • No inline JavaScript, so it passes a strict CSP that only allows our domain or a nonce on the tag.

Cons

  • Calls to our API made before the loader runs fail with Acme is not defined.

Inline snippet that defines a stub queue

A few lines of inline JavaScript define window.Acme as a function that pushes its arguments into Acme.q, then inject our script.

When the loader arrives, it replays the queue in order and replaces the stub.

Pros

  • Developers can call Acme("boot", ...) anywhere, at any time, even before we load.
  • The page never waits for us.

Cons

  • Inline script needs a nonce or hash on strict CSP pages.
  • A longer snippet that customers sometimes paste half of.

Global settings object

The page sets window.AcmeSettings = { appId, userId } before the tag, and the loader reads it. Intercom's classic install uses a similar window.intercomSettings object.

Pros

  • Server-rendered sites can print user data into the page easily.

Cons

  • Only works for settings known at render time.
  • Another global name that can collide.

What we pick: We support the first two with one loader. The default snippet is the async tag with data-app-id; developers who need the API before load add the three-line stub. The loader reads data-* attributes, then any settings passed through the queue. The stub queue is the same pattern the analytics SDK guide builds in depth, so we only summarize it here.

html
<script>
  window.Acme = window.Acme || function () {
    (window.Acme.q = window.Acme.q || []).push(arguments);
  };
</script>
<script async src="https://widget.acme.io/v1/loader.js"
        data-app-id="app_7Kq2"></script>
<script>
  Acme("boot", { userId: "u_381", userHash: "9f2c..." });
</script>
The full snippet with the optional stub. The page keeps parsing while loader.js downloads.

What the loader does, in order

ts
(() => {
  const tag = document.currentScript as HTMLScriptElement | null; // only valid right now
  if (window.__acme) return; // pasted twice: keep the first copy
  const appId = tag?.dataset.appId;
  if (!appId) return console.warn("[Acme] missing data-app-id");
  const nonce = tag?.nonce || undefined; // reuse the page's CSP nonce
  const queue: unknown[][] = (window.Acme as any)?.q ?? [];
  window.__acme = { appId, nonce, queue, t0: performance.now() };

  fetch(`https://api.acme.io/v1/apps/${appId}/widget-config`, { credentials: "omit" })
    .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
    .then((config) => {
      if (config.disabled || !matchesPageRules(config.pages, location.pathname)) return;
      const s = document.createElement("script");
      s.src = config.release.launcherUrl; // e.g. /v1/3.14.2/launcher.8c1e.js, immutable
      s.integrity = config.release.launcherIntegrity; // SRI on the second hop
      s.crossOrigin = "anonymous"; // SRI on another origin needs CORS
      if (nonce) s.nonce = nonce;
      s.async = true;
      document.head.append(s);
    })
    .catch(() => {}); // never throw into the host page
})();
loader.js, about 2.5 KB gzipped. It reads, checks, and hands off; it renders nothing itself.
  • Read document.currentScript first. It points at our tag only while the script runs for the first time; inside a callback it is null.
  • Guard against double loads. Customers paste the snippet in the theme and again in a tag manager. Two copies would draw two bubbles.
  • Fetch the config before anything else. The config says whether the widget is enabled on this page, which version to load, the brand color, and the kill switch. One small JSON request, cached at the CDN for 60 seconds.
  • Load the launcher bundle by its versioned URL from the config. The loader stays tiny and evergreen; the code that does work is immutable and cacheable for a year.
  • Swallow our own errors. An exception from our code must never reach the host page's error handlers or its console as a red wall.
Sequence diagram of loading the widget. The host HTML has an async script tag for loader.js and keeps parsing. The loader requests the widget config from the Config API, which replies with the release and the theme. The loader then requests the versioned launcher bundle, launcher.8c1e.js, from the CDN, which replies with the immutable 12 KB file. The launcher draws the bubble and replays the queued calls on the host page. If the config fails or says disabled, the loader stops quietly and the page is unaffected.Host pageloader.jsConfig APICDNasync tagGET configrelease, themeGET launcherimmutable, 12 KBdraw, replay queueconfig fails or disabled: stop quietly, page unaffected
The host page never waits: the tag is async, and every step after it happens off the parser's path. The only evergreen file is the loader; the bundle URL comes from the config.

The Third-party scripts and supply chain lesson explains why customers grow wary of every new tag. Our snippet's size, its async attribute, and its error handling are the first things a careful customer's engineer will review.

Check your understanding · Exercise 2

The loader reads document.currentScript inside the .then of its config fetch and gets null. Which statements are true? Select all that apply.

Choosing an isolation boundary

This is the biggest decision in the design. Isolation means two things here: the host page can't change how our widget looks or behaves, and our widget can't change the host. There are three tools, and they isolate very different things.

Decision Where does the widget's UI live?

Plain DOM with prefixed classes

Render into a div appended to body. Every class starts with a prefix like acme-, and a reset stylesheet scoped to #acme-root tries to undo host styles.

Pros

  • Smallest and simplest. Inherits the host's fonts if we want it to.
  • No frame boundary: focus, sizing, and events just work.

Cons

  • Host selectors like button, div p, or * still match our elements. A scoped reset can never list every property a host might set.
  • Our JavaScript shares globals with the host, and the host can read every message the visitor types.

Shadow DOM inside a custom element

Create a host element acme-widget, call attachShadow({ mode: "closed" }), and render inside the shadow root with our own styles.

Pros

  • Host selectors can't reach inside the shadow root, and our styles can't leak out.
  • Same document: no frame, light weight, and focus and layout behave normally.

Cons

  • Inherited properties (font-size, color, line-height) and CSS custom properties still flow in from the host.
  • No JavaScript isolation: same globals, same patched fetch. A host script can read the DOM text and listen to key events, even with a closed root.

Cross-origin iframe

Load our UI as a separate document from our own origin, like https://frame.acme.io/panel. The host page and the frame talk only through postMessage.

Pros

  • Full isolation: separate CSS, separate globals, separate rem root, separate CSP. The host can't read anything inside.
  • Our document runs in our own security context, so tokens and messages stay private.

Cons

  • Costs a full document: an extra request and some memory.
  • Sizing, focus, and screen reader announcements must be coordinated across the frame.
  • Storage inside the frame is partitioned per host site, and third-party cookies are often blocked.

What we pick: No single boundary fits both halves of the widget, so we use a hybrid. The launcher lives in a closed shadow root: it is tiny, holds no secrets, and needs to be on every page fast. The panel lives in a cross-origin iframe: it is large, handles private data, and needs full isolation. Payment fields, if we add them, get their own iframes.

Boundary
Host page

shadow DOM: 4 broken, 1 risky, 0 safe.

Isolation lab. shadow DOM: 4 broken, 1 risky, 0 safe. * rule: host box moves. html 62.5%: rem shrinks 37%. body color: pink text. max z-index: hidden behind. reads input: host can read.host pagehost content4 broken* rulehost box moveshtml 62.5%rem shrinks 37%body colorpink textmax z-indexhidden behindreads inputhost can read
Without fixes, every boundary has gaps. Turn on the guide's fixes to see which gaps are closable with CSS and which need a separate realm.

Try the lab with every hostile rule turned on. Plain DOM breaks on almost everything. Shadow DOM survives the selector rules but not the inherited color or the host's root font size, and does nothing against the patched fetch or the z-index war.

The iframe's document survives everything, but the iframe element itself still lives in the host's DOM, so rules that size or stack that element still reach it. The next chapter closes those gaps.

What each boundary isolates

ThreatPlain DOM + prefixShadow DOMCross-origin iframe
Host selectors (button, *, .card)Leak inBlockedBlocked
Inherited properties (font-size, color)Leak inLeak in (reset at :host)Blocked
CSS custom propertiesLeak inLeak inBlocked
rem unitsHost's root sizeHost's root sizeOur own root
Our CSS leaking outPossibleBlockedBlocked
Patched globals (fetch, Array.prototype)SharedSharedSeparate realm
Host reading visitor inputYesYesNo
Host listening to our eventsYesYes, retargeted to the host elementNo
Focus and tab orderNaturalNaturalCrosses a boundary
Host CSP applies to usYesYesOnly frame-src; the frame has its own CSP

Why payment fields always use iframes

Stripe Elements is the clearest real example. Each Element, such as the card number field, is a small iframe served from Stripe's domain. The merchant's page shows the fields and styles them through options Stripe exposes, but the raw card number exists only inside Stripe's frame. The merchant's JavaScript can't read it, so a compromised analytics script on the checkout page can't skim it either.

That matters for compliance too. Under the Payment Card Industry Data Security Standard (PCI DSS), merchants whose pages never touch card data can use the shortest self-assessment, SAQ A, though they still answer for the scripts on the page around the fields. Stripe also requires merchants to load Stripe.js from js.stripe.com rather than self-hosting it, so the code that creates those frames can't be swapped. Shadow DOM could never give this guarantee: it is a styling boundary, not a security boundary.

What real vendors do

Large chat widgets have converged on iframes for the main UI. Inspecting sites that use Intercom or Zendesk, for example, shows their messengers rendered inside iframes, with separate frames for the launcher and the messenger. Other vendors draw the launcher in shadow DOM and keep only the panel in a frame. Vendors change these internals often and don't document them, so treat this as a pattern, not a spec.

Our split is a reasonable middle: one fewer document on every page view, full isolation where it counts. The Micro-frontend shell guide weighs the same three tools from the other side, as the owner of a page that loads other teams' code, so we don't repeat its comparison here.

What each boundary isolates. Host selectors: leak into plain DOM, blocked by shadow DOM and by an iframe. Inherited CSS: leaks into plain DOM, must be reset in shadow DOM, blocked by an iframe. Rem units: follow the host root in plain DOM and shadow DOM, use the frame's own root in an iframe. JavaScript globals: shared in plain DOM and shadow DOM, a separate realm in an iframe. Can the host read visitor input: yes for plain DOM and shadow DOM, no for an iframe.plain DOMshadow DOMiframehost selectorsleak inblockedblockedinherited CSSleak inreset itblockedrem unitshost roothost rootown rootJS globalssharedsharedown realmhost reads inputyesyesno
Shadow DOM fixes only the first row fully. The iframe is the only column with no red, which is why anything private goes there, and why the cheap launcher can live with the gaps of shadow DOM.

Check your understanding · Exercise 3

A customer asks why the launcher isn't in an iframe too, since iframes isolate better. Which are good reasons for our choice? Select all that apply.

Hardening the boundary: CSS and JavaScript

Choosing shadow DOM and an iframe is the start, not the end. Each has gaps that real host pages find within a week of launch: styles that still cross the shadow boundary, and host globals our host-realm code still shares. This chapter closes them one by one, CSS first.

What still crosses the shadow boundary

A shadow root blocks selectors: a host rule like button { color: red } can't match a button inside our root. It does not block inheritance. The shadow host element acme-widget sits in the host's DOM, so it inherits font-family, font-size, color, line-height, letter-spacing, text-align, direction, and visibility from its parents, and passes them to everything inside. CSS custom properties, like a host's --primary, inherit the same way.

Host rules also still match the host element itself, because it is a normal element in their DOM. A * rule, a rule like body > *, or [hidden] styles all apply to acme-widget. And in the cascade, a normal declaration from the outside page beats our :host rule for the same property. Our only edge is !important: inside the shadow root's :host rule, an important declaration beats the page's important declarations.

css
:host {
  all: initial !important;       /* drop inherited font, color, line-height */
  position: fixed !important;
  inset: auto 20px 20px auto !important;
  display: block !important;
  contain: layout style !important; /* our layout can't affect theirs */
}
.root {                           /* our wrapper, the first child of the root */
  font: 400 14px/1.4 system-ui, sans-serif;
  color: #1a1a1a;
}
.launcher {
  inline-size: 56px;              /* px, never rem: the host owns the root size */
  block-size: 56px;
  border-radius: 50%;
  background: var(--acme-accent, #4f46e5);
}
The launcher's shadow stylesheet. Reset at the boundary with important declarations, then set every inherited value again on our own wrapper, in px.

Every declaration on :host must be important. An important all: initial beats any normal declaration in the same rule, so a plain font or contain next to it would be reset too. That is why the font and color live on .root, which inherits nothing from the page once :host is reset.

  • all: initial resets every property on the host element to its initial value, which cuts inheritance from the page. It does not reset custom properties (or direction), so a host's --primary still flows in. That is fine as long as we only read our own prefixed names like --acme-accent.
  • Never use rem in the launcher. rem is relative to the root html element, which the host owns. A host with html { font-size: 62.5% } makes 1rem equal to 10 px instead of 16 px. Use px, or em relative to the 14px we set on .root. Inside the iframe rem is safe again, because the frame has its own root.
  • Name the element something only we use. A host rule like div { display: flex } can't match acme-widget. A * rule still can, which is why the important reset matters.

The z-index war and the top layer

Hosts and other widgets fight for the top of the page with z-index: 2147483647, the largest 32-bit integer. Matching it only works until someone else does the same, and then the order depends on which element comes later in the DOM. Worse, z-index only competes inside a stacking context. If the host puts transform or filter on body, a new containing block forms too, and our position: fixed launcher scrolls away with the page.

The top layer solves both. It is a separate layer the browser paints above the whole document, outside every stacking context. Elements get there through dialog.showModal(), the Fullscreen API, or the popover attribute.

A popover="manual" element shown with showPopover() sits in the top layer, isn't affected by ancestors' transform, overflow, or z-index, and doesn't close on outside clicks. The popover attribute has been Baseline in all major browsers since 2024.

ts
const wrap = document.createElement("div");
wrap.setAttribute("popover", "manual");
wrap.className = "wrap"; // styled in our shadow sheet: position, size
shadow.append(wrap);
if (typeof wrap.showPopover === "function") {
  wrap.showPopover();
} else {
  wrap.classList.add("fallback"); // position: fixed; z-index: 2147483647
}
The launcher's wrapper joins the top layer. Inside the top layer, the last element shown is on top.

The top layer has rules of its own. If the host later opens its own modal dialog, theirs goes above ours and the rest of the page, including our launcher, becomes inert. That is the correct behavior: a cookie consent dialog or a checkout modal should win. The Modal dialog guide covers the top layer and inertness in depth.

Which host CSS reaches the launcher's shadow root. On the left, four host rules. A button selector is blocked at the shadow boundary. Body font and color inherit into the host element, where the reset with all: initial stops them. A host custom property named primary flows straight through to the launcher, because all does not reset custom properties. The html font size of 62.5 percent changes what rem means everywhere, so the launcher uses px.Host page CSSacme-widget shadow rootbutton {...}body: font--primary: limehtml: 62.5%:host resetall: initiallauncherpx sizes, --acme-*blockedinheritsflows inrem follows
Selectors stop at the boundary; inheritance doesn't. all: initial on :host cuts inherited values, but custom properties and the root's rem still come through, so read only --acme-* names and size in px.

Protecting the iframe element

The frame's document is safe, but the iframe element is in our shadow root. Put it there, not in the page, or a host rule like iframe { width: 100%; border: 4px solid } (common in themes that make video embeds responsive) stretches our panel across the page. Inside the shadow root, set its size, border, and color scheme explicitly. Set color-scheme on the frame and on its document to match, or browsers paint an opaque white background behind a dark panel.

Sizing an iframe to its content

An iframe doesn't grow to fit its content: the parent picks its size. A floating panel can use a fixed size (for example 400 by 640 px, or the full screen on phones). An inline embed, like a Calendly page or a comments thread, needs its height to follow the content. The frame measures itself and tells the parent.

ts
let last = 0;
let queued = false;
new ResizeObserver(() => {
  if (queued) return;
  queued = true;
  requestAnimationFrame(() => {
    queued = false;
    const h = Math.ceil(document.documentElement.getBoundingClientRect().height);
    if (Math.abs(h - last) < 2) return; // ignore sub-pixel jitter
    last = h;
    port.postMessage({ v: 1, type: "frame:resize", height: h });
  });
}).observe(document.documentElement);
Inside the frame: report height changes, batched to one message per frame.

On the parent side, clamp the value (say 80 to 4,000 px) so a buggy or malicious frame can't make itself 10 million pixels tall, and reserve the initial height up front so the first resize doesn't shift the host's content. A feedback loop is the classic bug: if the frame's content uses 100vh, growing the frame grows the content, which grows the frame. Size the frame's content by its own content, never by its viewport.

Check your understanding · Exercise 4

A host page has html { font-size: 10px }, body { color: hotpink; line-height: 3 }, and :root { --primary: lime }. Our launcher uses a closed shadow root with :host { all: initial }. What still affects it? Select all that apply.

JavaScript: surviving the host's globals

Our loader and launcher run in the host's realm: one set of globals (window, Array, fetch, JSON) shared by every script on the page. Host pages change those globals all the time, usually for good reasons of their own.

  • Patched prototypes. Old libraries such as Prototype.js and MooTools added methods to Array.prototype and friends. Prototype once added Array.prototype.toJSON, which made JSON.stringify encode arrays as strings. A for...in over an array suddenly lists extra keys.
  • Wrapped fetch and XMLHttpRequest. Monitoring tools, ad blockers, and app code wrap them to log, add headers, or mock. A careless wrapper drops the body, breaks keepalive, or rejects cross-origin URLs.
  • Replaced timers and promises. Angular apps load Zone.js, which patches setTimeout, addEventListener, Promise, and more, so Angular knows when to run change detection. Our timers, if they run inside Angular's zone, can make the host app re-check its whole view on every tick.
  • Polyfills that are wrong. An old Promise or Object.assign polyfill installed over the native one, missing modern behavior.
  • Global name clashes. A host already has a window.Acme, or another widget defined the custom element name we want. customElements.define throws if the name is taken.

And it goes the other way. If our launcher adds a global, defines a polyfill, or patches history.pushState to detect route changes without care, we can break the host. Isolation here means: take as little as possible from the shared realm, and put nothing back.

Decision How do we protect our code from patched globals?

Trust the globals

Call fetch, JSON.stringify, and setTimeout as usual.

Pros

  • Simplest code.

Cons

  • Breaks on a small but steady share of sites, in ways we can't reproduce without their exact page.

Capture native references at load

At the top of the loader, save const nativeFetch = window.fetch.bind(window) and similar, then use only those.

For a cleaner copy, create a hidden same-origin about:blank iframe and take globals from its fresh contentWindow.

Pros

  • Protects against scripts that patch after us.
  • A fresh frame gives truly native functions even if the host patched before us.

Cons

  • A capture at load time still gets the patched version if the host patched first.
  • The clean-frame trick costs a document and must be kept attached; some security tools flag it.

Keep logic in our own frame

The launcher in the host realm does almost nothing: draw, count, forward clicks. Network calls, state, and logic run inside our cross-origin iframe, with a realm we fully control.

Pros

  • Host patches can't touch the code that matters.
  • The host-realm surface is small enough to audit line by line.

Cons

  • Before the panel frame exists, the launcher still needs one call for the unread count.

What we pick: We combine the last two. The host-realm code is small and defensive: it captures the few natives it needs at the very start of the loader and checks them. Everything with real logic, including the chat connection, runs inside our frame. The one thing the launcher fetches itself is the unread count, which isn't secret: one small request with the captured native fetch.

ts
const isNative = (fn: unknown) =>
  typeof fn === "function" &&
  /\{\s*\[native code\]\s*\}\s*$/.test(Function.prototype.toString.call(fn));

const natives = {
  fetch: isNative(window.fetch) ? window.fetch.bind(window) : null,
  stringify: JSON.stringify,
  raf: window.requestAnimationFrame.bind(window),
};

function defineOnce(name: string, ctor: CustomElementConstructor) {
  if (!customElements.get(name)) customElements.define(name, ctor);
}
Defensive helpers in the host realm. Check that a native is really native before trusting it.

The native check is a heuristic: a determined page can fake it. We don't defend against a page that targets us on purpose (it owns the page and can simply remove our tag). We defend against accidental breakage, which is the common case.

Rules for leaving no trace

  • One global, or none. Only window.Acme (the public API) and one private key like window.__acme. Everything else lives in a closure.
  • No polyfills in the host realm. Ship modern syntax compiled for our browser targets, with helpers bundled locally. Never assign to window.Promise or Array.prototype.
  • No patching host APIs. To detect route changes in single-page apps, listen for popstate and use the Navigation API where it exists, or let developers call Acme("update"). Patching history.pushState collides with the host's router and other widgets doing the same.
  • Listeners on our own nodes. Attach events to our shadow root, not to document. When we must listen on window (for resize or pagehide), use { passive: true } and remove them on teardown.
  • A teardown function. Acme("shutdown") removes our element, our listeners, our timers, and our frame. Single-page app customers call it on logout.
  • Stay out of host zones. In Angular hosts, our callbacks registered from host events can run inside the Angular zone. Running heavy work inside our iframe avoids this entirely; for the launcher, keep timers rare.

Think first

A customer's site uses an old analytics library that wraps XMLHttpRequest. Our panel inside the iframe uses fetch and a WebSocket. Is our panel affected?

Check your understanding · Exercise 5

Which practices reduce the chance that our launcher breaks the host page? Select all that apply.

Talking across the frame boundary

The launcher lives in the host page; the panel lives in https://frame.acme.io. They need to talk: open and close, unread counts, resize, the developer's Acme("boot") call, and events going back out to Acme("on", ...) listeners. The only channel between a page and a cross-origin frame is postMessage.

The naive version is iframe.contentWindow.postMessage(data, "*") on one side and window.addEventListener("message", e => handle(e.data)) on the other. Both halves are dangerous. The "*" target sends our data to whatever document is in that frame right now, even if it navigated somewhere else.

The listener accepts messages from any window: every ad frame and every other widget on the page can post to it. A message like { type: "open", url: "javascript:..." } from an ad becomes our bug.

Three rules, both directions

  1. Always send with an exact target origin. The host side sends with "https://frame.acme.io". The frame sends to the parent with the host's exact origin. If the document there doesn't match, the browser drops the message.
  2. Always check who sent it. Check event.origin against the expected origin and event.source against the exact window (iframe.contentWindow or window.parent). Origin alone isn't enough when the host page has several of our frames.
  3. Treat every message as untrusted input. Validate the shape and types, ignore unknown types, and never pass message fields into innerHTML, eval, or a navigation without checking them.

The CodePen guide builds these same checks in code for its preview frame, so we only list them here. The difference is who we distrust: CodePen's editor distrusts the user code in the frame, while our frame distrusts the host page around it. We apply the checks on both sides.

A private port with MessageChannel

Checking origins on every message event on window works, but the window channel is a shared, noisy bus. A MessageChannel gives us a private pipe. It makes two connected ports; we keep port1 and transfer port2 to the frame inside one handshake message. After that, nothing else on the page can post into our conversation, and neither side needs a global message listener.

Sequence diagram of the port handshake between the launcher, an ad iframe on the same page, and our panel frame. The launcher creates the panel iframe. The ad iframe posts a fake frame hello to the window; the launcher drops it because its source is not our iframe. The panel frame posts frame hello with a nonce, sent to the host's exact origin. The launcher replies with host connect and transfers port2 of a new MessageChannel. From then on the frame posts frame ready on the port, and the launcher sends host open on the port.LauncherAd iframePanel framecreate iframefake frame:hellodropped: wrong sourceframe:hello, noncehost:connect + port2frame:ready (port)host:open (port)
The window channel is used once, with exact origins and a source check. After host:connect, both sides talk only on the private port, so the ad's messages can never reach the conversation.
ts
const FRAME_ORIGIN = "https://frame.acme.io";
function connect(iframe: HTMLIFrameElement, config: WidgetConfig): Promise<MessagePort> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => done(new Error("frame:timeout")), 10_000);
    function onHello(e: MessageEvent) {
      if (e.origin !== FRAME_ORIGIN || e.source !== iframe.contentWindow) return;
      if (e.data?.type !== "frame:hello" || !e.data.protocols?.includes(1)) return;
      const { port1, port2 } = new MessageChannel();
      iframe.contentWindow!.postMessage(
        { v: 1, type: "host:connect", nonce: e.data.nonce, config },
        FRAME_ORIGIN,
        [port2],
      );
      done(null, port1);
    }
    function done(err: Error | null, port?: MessagePort) {
      clearTimeout(timer);
      window.removeEventListener("message", onHello);
      if (err) reject(err); else resolve(port!);
    }
    window.addEventListener("message", onHello);
  });
}
Host side of the handshake. Only a hello from our exact frame gets the port.

The frame side mirrors it. It learns the expected parent origin from a parentOrigin query parameter set by the launcher, and checks it against the customer's allowed domains from the config. It sends frame:hello with that exact target origin, so if the parameter lies, the browser won't deliver the message. The browser itself also blocks the frame from loading on unlisted sites, through frame-ancestors, covered in the security chapter.

When host:connect arrives, the frame checks event.origin and event.source === window.parent, then takes event.ports[0] and ignores the window channel from then on. One detail trips people up: a port only delivers messages once it is started. Setting port.onmessage starts it; a listener added with addEventListener needs an explicit port.start().

A typed, versioned protocol

ts
type HostToFrame =
  | { v: 1; type: "host:connect"; nonce: string; config: WidgetConfig }
  | { v: 1; type: "host:boot"; user?: { id: string; hash: string; name?: string } }
  | { v: 1; type: "host:open"; view?: "home" | "messages" | "article"; articleId?: string }
  | { v: 1; type: "host:close" }
  | { v: 1; type: "host:visibility"; visible: boolean };

type FrameToHost =
  | { v: 1; type: "frame:hello"; nonce: string; protocols: number[] }
  | { v: 1; type: "frame:ready" }
  | { v: 1; type: "frame:resize"; height: number }
  | { v: 1; type: "frame:unread"; count: number; announce?: string }
  | { v: 1; type: "frame:close"; returnFocus: boolean }
  | { v: 1; type: "frame:event"; name: "message" | "conversation-started"; data: unknown }
  | { v: 1; type: "frame:error"; code: string };
Every message has a version and a type. Requests that need an answer carry an id.
  • Two versions run at once. The loader is cached for minutes and the frame for longer, so during a rollout a new launcher can talk to an old frame, or the reverse. The hello lists the protocol versions each side speaks, and they use the highest common one.
  • Only additive changes within a version. New optional fields and new message types are fine; both sides ignore what they don't know. Renaming or removing a field means a new version number, supported side by side for at least one release cycle.
  • Structured clone, not JSON strings. postMessage copies objects with the structured clone algorithm. Functions and DOM nodes can't be cloned (the call throws), and class instances arrive as plain objects, which keeps the protocol plain data by design.

Check your understanding · Exercise 6

The frame receives { v: 1, type: "host:open" } on the window message event, after the port handshake finished. event.origin is the customer's own origin. What should it do? Select all that apply.

Architecture, data model, and API

We now have enough decisions to draw the whole system. The key idea is that each piece runs in the cheapest place that is safe for it. Code in the host realm is small, defensive, and holds nothing secret. Code in our frame can be large, uses any framework, and holds the visitor's session.

Architecture of the widget. On the left, the host page on the customer's origin holds the loader.js file, the command queue of early calls, and the launcher in a shadow root that owns the Acme API. The loader loads the launcher, and the queue is flushed into it. On the right, our frame on frame.acme.io holds the panel app, the session and message cache, and the API and socket client. The launcher talks to the panel app over a private port. At the bottom, our servers: the CDN with the config API, which the loader calls, and the chat API, which only the frame calls with credentials.Host page (customer origin)frame.acme.io (our origin)Launchershadow root, Acme() APIloader.js2.5 KBqueueearly callsloadsflushPanel appviews, composerSession, cachetoken, messagesAPI + socketwith credentialsportOur serversCDN + config APIChat API
Two realms, one port between them. The host side is small and holds nothing secret; the frame side holds the session and does the real work. Only the frame talks to the chat API with credentials.
PieceRuns inSize (gzip)Responsibility
Snippet and stubHost page, inlineUnder 300 bytesQueue early API calls, inject the loader.
LoaderHost realm, widget.acme.io/v1/loader.jsAbout 2.5 KBRead the tag, fetch config, check page rules and kill switch, load the launcher bundle.
LauncherClosed shadow root in the host pageAbout 12 KBDraw the bubble and badge, own the public Acme() API, create the panel frame, run the port, return focus.
Panel appCross-origin iframe on frame.acme.ioAbout 180 KBHome, articles, conversation, composer, uploads. Holds the session token and the real-time connection.
Config APIOur edge, cached 60 sAbout 2 KB JSONPer-customer settings: theme, texts, page rules, allowed domains, version channel, kill switch.
Chat API and socketOur serversNot shippedMessages, unread counts, presence. Only the frame talks to it with credentials.
Error and metrics collectorOur serversNot shippedErrors and timings tagged by app id and version, no host page content.

Why the boundaries sit where they do

  • Loader and launcher are separate files because they have opposite caching needs. The loader URL never changes, so it can only be cached briefly. The launcher URL changes with every release, so it can be cached forever. Keeping the evergreen file tiny keeps the cost of its short cache low.
  • The public API lives in the launcher, not the frame. Acme("open") must work before the 180 KB panel exists. The launcher queues commands for the frame and flushes them once the port is ready.
  • The session lives only in the frame. The host realm never sees a token, so a compromised script on the host page can at worst read what's on screen in the launcher: a number.
  • Config is data, not code. Theming, texts, and rules arrive as JSON. Changing a customer's color never needs a new bundle, and a kill switch works without a deploy.
Data flow for one page view. Top row: the config API on the CDN sends config JSON to the loader, which loads the launcher code. The launcher sends commands over the port down to the panel frame, and receives unread counts and resize messages back. Bottom row: the panel frame sends messages to the socket, which sends them to the chat API; events come back the same way. A note says that with no click, only the top row runs, about 17 KB.Config CDN60 s cacheLoader2.5 KBLauncher12 KB, shadowJSONcodecommandsunread, sizeChat APImessagesSocketwssPanel frame180 KB, on intentsends left, events come back rightno click: only the top row, about 17 KB
Data moves only as far as the visitor needs. Without a click, the bottom row never starts. With one, private data stays on the bottom row, inside our origin, and the host side only ever sees counts and sizes.

The data flow figure follows one page view. Bytes move only as far as they need to. On a page view where the visitor never clicks, the bottom row (the frame, the panel bundle, the socket) is never touched: about 17 KB in three requests, plus the unread-count call when the visitor is known.

The key flow: opening the panel

Sequence diagram of opening the panel. The visitor clicks the launcher. The launcher swaps its icon and shows a spinner in the same frame. Then it creates the iframe. The panel frame posts frame hello. The launcher sends connect with the queued commands. The frame posts to the chat API to create a session and receives a token and threads. The frame posts frame ready, and the launcher shows it and moves focus inside. Failure path: if no hello arrives within 10 seconds, the launcher shows an inline fallback with a link to the help center.VisitorLauncherPanel frameChat APIclickicon swap, spinnercreate iframeframe:helloconnect + queuePOST sessionstoken, threadsready: show, focusno hello in 10 s: inline fallback and a help link
Feedback comes before any heavy work, so the click itself stays cheap for INP. The session is created by the frame, never by the launcher. The timeout path means a blocked frame still leaves the visitor a way forward.

When the visitor clicks the launcher, it gives feedback in the same frame: the icon turns into a close icon and a spinner ring starts. Only then does it create the iframe, so the click itself costs a few milliseconds of host main-thread time, which keeps the Interaction to Next Paint (INP) of that click low. The frame loads, says hello, gets the port and the queued commands, renders, and posts frame:ready. The launcher then shows the frame and moves focus into it.

The failure path matters as much. If the frame doesn't say hello within 10 seconds (blocked by the host's CSP, a corporate proxy, or our outage), the launcher shows a small inline message in its own shadow root with a link that opens the help center in a new tab. The visitor always has a way forward, and we report frame:timeout with the app id.

State machine of the launcher. It starts in booting while the config loads. If the config is fine, it moves to closed. A click moves it to loading, and the frame's ready message moves it to open. Close or Escape moves it from open back to closed. From booting, a disabled config or a page rule that doesn't match moves it to disabled. From loading, a 10 second timeout moves it to failed, and a later click retries back through closed. A note says shutdown from any state removes everything.bootingclosedloadingopenconfig okclickreadyclose or Escdisabledfaileddisabled or no match10 s timeoutretryshutdown from any state: remove all
Each event is legal only in some states: a second click while loading is ignored, and a late ready after the timeout is dropped. That is what stops a fast double click from creating two frames.

The launcher's state machine keeps these paths honest. Every event is legal only in certain states: a second click while loading is ignored, a frame:ready that arrives after the timeout fired is dropped, and shutdown from any state tears everything down. Writing it as an explicit machine prevents the bugs where a fast double click creates two frames.

Check your understanding · Exercise 7

Where should each of these live? Which placements are correct? Select all that apply.

Data model: where each piece lives

A widget has little data of its own, but where each piece lives is a security decision. The rule above holds: anything that identifies or authenticates the visitor stays on our origin; the host realm gets only what it needs to draw the launcher.

ts
type WidgetConfig = {
  appId: string;
  disabled: boolean;                 // kill switch for this customer
  release: {
    launcherUrl: string;             // "/v1/3.14.2/launcher.8c1e.js"
    launcherIntegrity: string;       // "sha384-...", checked by the browser
    frameUrl: string;                // "https://frame.acme.io/3.14.2/panel"
    protocols: number[];             // message protocol versions, e.g. [1]
  };
  allowedOrigins: string[];          // "https://shop.example.com"
  pages: { include: string[]; exclude: string[] }; // path patterns
  theme: {
    accent: string;                  // "#4f46e5"
    side: "left" | "right";
    offset: { x: number; y: number };
    scheme: "light" | "dark" | "auto";
  };
  texts: { launcherLabel: string; greeting: string };
  locale: string;                    // "fr-FR", or "auto" for the browser's
  sampling: { errors: number; timings: number }; // 0 to 1
};
The config the loader fetches. Everything a customer sets in the dashboard, plus release control.
DataLives inWhy there
Widget configCDN cache, then launcher memory, copied to the frame in host:connectPublic, the same for every visitor of a site. Cached at the edge for 60 s.
Anonymous visitor idHost page first-party localStorage, key acme:vid:app_7Kq2Survives across pages and visits on that site, isn't blocked as a third-party cookie, and isn't secret.
Session tokenFrame memory and frame storage on frame.acme.ioAuthenticates the visitor to the chat API. The host realm must never hold it.
Conversations and messagesFrame: a server-state cache, refreshed by the socketPrivate. Rendered only inside the frame.
Unread countLauncher memoryShown on the badge. A number is safe to hold in the host realm.
Panel open or closedHost sessionStorageOn multi-page sites, a visitor mid-chat expects the panel to stay open on the next page.
Queued API callsLauncher memoryFlushed to the frame when the port opens.
Where the widget's data lives. On the left, storage on the customer's origin, which host scripts can read: the anonymous visitor id in localStorage, whether the panel is open in sessionStorage, and the unread count in launcher memory. On the right, storage on frame.acme.io, partitioned by the top-level site: the session token in a partitioned cookie, messages in the frame's memory cache, and a fallback token in the frame's localStorage.Customer origin: host can readframe.acme.io, per top sitevisitor idlocalStoragepanel open?sessionStorageunread countlauncher memorysession tokenpartitioned cookiemessagesframe memory cachefallback tokenframe localStorageno token, no message text ever crosses to the left
The left side holds nothing that proves who the visitor is. Everything that does sits on the right, on our origin, and each customer site gets its own partition of it.

The public JavaScript API

The API is one function with a command name, the shape the stub queue can capture. Every command is safe to call before load, safe to call twice, and never throws into the host's code: bad arguments log one warning and are ignored.

ts
Acme("boot", { userId: "u_381", userHash: "9f2c...", name: "Ana" });
Acme("update", { page: "/pricing" });      // single-page apps, after a route change
Acme("open", { view: "messages" });
Acme("close");
Acme("on", "unread", (count: number) => updateNavBadge(count));
Acme("on", "conversation-started", (e: { id: string }) => track(e));
Acme("shutdown");                          // on logout: remove UI, clear the session
Commands developers can call. Events go out through Acme("on").

The userHash is how we know the user id is real. Without it, anyone could open the browser console on a customer's site, call Acme("boot", { userId: "u_1" }), and read user 1's conversations.

The customer's server computes an HMAC (a keyed hash) of the user id with a secret only they and we know, and prints it into the page. Our API checks it before linking the session to that user. Intercom calls this identity verification, and most chat vendors have an equivalent.

What the frame calls

http
GET  /v1/apps/app_7Kq2/widget-config          (loader, no credentials, CDN 60 s)
GET  /v1/apps/app_7Kq2/unread?vid=v_9a1        (launcher) -> { "count": 2 }
POST /v1/apps/app_7Kq2/sessions                (frame)
     { "vid": "v_9a1", "userId": "u_381", "userHash": "9f2c..." }
  -> { "token": "st_...", "expiresIn": 3600, "user": { "id": "u_381" } }
GET  /v1/conversations?limit=20&cursor=...     (frame, Authorization: Bearer st_...)
WSS  /v1/socket?token=st_...                   (frame: message, typing, read)
Only the config and unread calls come from the host realm, without credentials. Everything else comes from the frame.

Errors follow one shape: { "error": { "code": "session_expired", "retryable": true } }. The frame refreshes the session on session_expired and backs off on retryable codes. The details of the conversation API, pagination, and the socket belong to the chat widget problem; here what matters is who is allowed to call what.

Check your understanding · Exercise 8

Why is the anonymous visitor id stored in the host page's first-party storage, not in the frame's storage? Select all that apply.

Configuration and theming per customer

Every customer wants the widget to look like their brand. Most of them are not developers: they'll pick a color in our dashboard and expect it live in a minute. A few are developers who want to match their design system exactly. The theming design has to serve both without letting either break the widget.

Decision How do customers customize the look?

Dashboard settings only

The customer picks accent color, side, offset, light or dark, and texts in our dashboard. They arrive in the config JSON.

Pros

  • No code. We validate every value, so nothing can break the layout.
  • Changes go live within the config cache time, without a deploy.

Cons

  • Limited: no exact fonts, radius, or per-page overrides.

CSS custom properties as a public API

We document a short list like --acme-accent, --acme-radius, --acme-font. The customer sets them in their own CSS on acme-widget.

Custom properties inherit through the shadow boundary, so the launcher picks them up directly.

Pros

  • Uses the platform: works in the customer's stylesheet, media queries, and dark mode toggles.
  • We choose which knobs exist.

Cons

  • They don't cross into the iframe. The launcher has to read and forward them.

Shadow parts (::part)

Mark elements with part="launcher" or part="badge". The host can style acme-widget::part(launcher) with any property, even with a closed root.

Pros

  • Full styling freedom for the parts we expose.

Cons

  • Every part becomes a public contract: renaming or restructuring it breaks customers' CSS.
  • Any property means customers can break sizing, focus rings, or contrast.

What we pick: All three, in layers. Dashboard settings are the default and cover most customers. A short, documented list of custom properties serves developers and also covers the panel, because the launcher forwards validated values into the frame. We expose exactly two parts, launcher and badge, and document that their internal structure may change. Arbitrary customer CSS inside the frame is not offered: that frame shows private data and must stay ours.

ts
const KNOBS = { "--acme-accent": "color", "--acme-radius": "length", "--acme-font": "font-family" } as const;

function readTheme(host: HTMLElement): Record<string, string> {
  const cs = getComputedStyle(host);
  const out: Record<string, string> = {};
  for (const [name, kind] of Object.entries(KNOBS)) {
    const value = cs.getPropertyValue(name).trim();
    if (!value || value.length > 120) continue;
    const ok =
      kind === "color" ? CSS.supports("color", value)
      : kind === "length" ? CSS.supports("border-radius", value)
      : CSS.supports("font-family", value);
    if (ok) out[name] = value;
  }
  return out; // merged over the dashboard theme, sent in host:connect
}
The launcher reads the customer's custom properties, keeps only valid values, and forwards them to the frame.

The order of precedence is: our defaults, then the dashboard theme, then the customer's custom properties, then Acme("update", { theme }) at run time. The frame applies the result as custom properties on its own :root, so the panel's stylesheet is identical for every customer. A font family only works inside the frame if the font is available there: system fonts and our hosted fonts work, a font the host page loaded doesn't, because the frame doesn't share the host's @font-face rules.

Guard rails that protect the visitor

  • Contrast check. If a customer picks a pale yellow accent, white text on it fails the 4.5 to 1 contrast ratio. The panel computes contrast and switches the text on the accent to near-black automatically. The dashboard warns too.
  • Color scheme. scheme: "auto" follows prefers-color-scheme, which works inside the frame. Hosts with their own dark mode toggle call Acme("update", { scheme: "dark" }). The Dark mode theming guide covers the rest.
  • Language and direction. The locale comes from the config, or from the host's html lang attribute when set to auto. Right-to-left languages flip the launcher to the other side and set dir="rtl" on the panel.
  • Page rules. Include and exclude path patterns let customers hide the launcher on checkout pages, where they don't want distractions.

Check your understanding · Exercise 9

A customer sets acme-widget { --acme-accent: #e11d48 } in their CSS. Where does the new color appear, and why? Select all that apply.

Versioning, caching, and safe rollouts

Customers paste our snippet once and forget it. Some snippets in the wild will be five years old. Meanwhile we ship fixes every week, and each release reaches 20,000 sites. The release system has to give us two things at once: updates that reach old snippets quickly, and a way to undo a bad release everywhere within minutes.

Decision How do script URLs and versions work?

One evergreen URL for all the code

widget.acme.io/widget.js is the whole widget, replaced on every release.

Pros

  • Every customer gets every fix.

Cons

  • It can only be cached briefly, so returning visitors re-check a 200 KB file often.
  • A bad release hits everyone at once, and undoing it waits for every cache to expire.

Customer pins an exact version

The snippet contains widget.acme.io/3.14.2/widget.js, cached forever.

Pros

  • Perfect caching, and the customer can use Subresource Integrity.

Cons

  • Fixes never reach customers who don't update. Security fixes too.
  • We support dozens of old versions against a moving backend.

Evergreen tiny loader, immutable versioned bundles

The snippet points at /v1/loader.js, a 2.5 KB file cached for 5 minutes. It fetches the config, which names the current bundle, like /v1/3.14.2/launcher.8c1e.js, cached for a year as immutable.

Breaking API changes get a new major path, /v2/loader.js. v1 keeps working.

Pros

  • Fixes reach everyone within minutes.
  • The big files are cached for a year. Browser caches are partitioned per site, so each customer site caches its own copy, but repeat visits to that site cost nothing.
  • A rollback is a config change, not a deploy.

Cons

  • Two hops before the launcher shows (loader, then config and bundle).
  • The evergreen loader can't use SRI.

What we pick: The tiny loader with immutable bundles. Its second hop costs about one round trip, which we spend while the host page is busy loading its own content anyway, and in return we get fast fixes and fast rollbacks. Customers who require SRI can opt into pinned URLs, with the trade-offs spelled out.

ResourceExample URLCache-ControlChanges
Loader/v1/loader.jspublic, max-age=300Rarely: only when the loader logic itself changes.
Config/v1/apps/app_7Kq2/widget-configpublic, max-age=60, stale-while-revalidate=60When the customer edits settings, or we move a release or flip a kill switch.
Launcher bundle/v1/3.14.2/launcher.8c1e.jspublic, max-age=31536000, immutableNever: a new release is a new URL.
Panel documentframe.acme.io/3.14.2/panelno-cache (revalidate with an ETag)Per release. Its own scripts and styles are hashed and immutable.
How releases reach customer sites. The customer's snippet points at the evergreen loader, /v1/loader.js, cached for 300 seconds. The loader fetches the config, cached for 60 seconds, which names a release per customer cohort. 90 percent of customers get the 3.14.2 bundle and 10 percent get 3.15.0. Both bundles are immutable and cached for a year. A note says the kill switch is disabled true in config, and a rollback moves the pointer.snippet/v1/loader.jsmax-age=300configmax-age=603.14.290% of customers3.15.010% of customersimmutable, cached a yearkill switch: disabledrollback: move pointer
Only the small middle boxes are short-cached, so a release or a rollback is a config change that lands within about two minutes. Old bundles stay on the CDN, so moving the pointer back is always safe.

Rolling out a release

Because the config names the bundle, the config service decides who gets which release. It assigns each customer (not each visitor) to a cohort by hashing the app id into a number from 0 to 99. A customer is either on the new release or not, so their support team never sees two versions at once and our errors are easy to attribute.

  1. Our own sites and a test harness of hostile pages (see the testing chapter).
  2. 1% of customers for a few hours, then 10%, then 50%, then 100% over two to three days.
  3. At each step, compare error rate, frame timeout rate, and launcher render time for the new version against the old one, per browser. A new version with 0.4% frame timeouts when the old had 0.1% stops the rollout automatically.

The kill switch has three levels, all in config data: roll a cohort back to the previous release, set disabled: true for one customer (their page shows no widget), or set a global flag that disables the widget everywhere. With a 60-second config cache and at most 60 more seconds of stale reuse, a rollback reaches most page views within about two minutes. The Feature flag SDK guide covers kill switches and gradual rollout in more depth; the same ideas apply here at the level of whole bundles.

Why the loader can't use Subresource Integrity

Subresource Integrity (SRI) lets a page say: run this script only if its bytes hash to this value, with an attribute like integrity="sha384-...". It protects the host from a compromised CDN. But the hash is fixed in the customer's HTML, and our loader is evergreen: the first time we change one byte, every page that pinned the old hash refuses to run it, and the widget disappears silently from those sites.

So SRI and evergreen updates are mutually exclusive for the file in the snippet. We still use SRI one hop later: the config carries the hash of the launcher bundle, and the loader sets integrity on the script element it creates. A customer who requires SRI on the first hop can pin /v1/3.14.2/loader.js with a published hash. They give up automatic fixes, but not the kill switch, because the kill switch is in config, which even pinned code reads.

Check your understanding · Exercise 10

Release 3.15.0 doubles the frame timeout rate on Safari. Which actions are fast and safe? Select all that apply.

A performance budget on someone else's page

Customers check their Core Web Vitals in Search Console and Lighthouse. When their numbers drop after installing us, they uninstall us. Our budget is not about our own speed; it is about how little we take from theirs. The Performance budgets and monitoring lesson covers how to set and enforce budgets in CI; here we set the numbers.

The targets below are our design goals. The sizes and timings next to them are estimates for a mid-range phone on 4G, to be replaced with field data once the widget ships.

BudgetTargetHow we meet it
Bytes before the visitor interactsUnder 20 KB gzippedLoader 2.5 KB, config 2 KB, launcher 12 KB. The 180 KB panel loads on intent.
Host main-thread time at loadUnder 15 ms total, no task over 10 msNo framework in the launcher: a few hundred lines of plain DOM code.
Layout shift we cause0The launcher is fixed and in the top layer. Inline embeds reserve space.
Click to feedbackUnder 50 msThe click handler only swaps the launcher's icon; frame work starts after the next paint.
Click to usable panelUnder 1 s on 4G for a first open, under 200 ms after a warm-upPreconnect and prefetch on intent; render the panel shell from the frame's HTML.

Decision When do we load the 180 KB panel?

Eagerly, at page load

The loader creates the hidden panel iframe right away, so opening it is instant.

Pros

  • Fastest open.

Cons

  • Every visitor pays about 180 KB and a few hundred ms of parsing, which competes with the host's own LCP image and scripts.
  • On phones, a cross-origin frame often shares the host's main thread, so its startup work can delay the host's interactions and hurt INP.
  • At 1.5 billion page views a month, that is about 270 TB of panel bytes, 98% of them never used.

When the browser is idle

After the host's load event, wait for an idle period with requestIdleCallback (falling back to a setTimeout where it doesn't exist), then create the hidden frame.

Pros

  • Doesn't compete with the host's loading.
  • Opening is fast for visitors who stay a few seconds.

Cons

  • Still downloads 180 KB for everyone who stays, and idle work can still land just as the visitor starts scrolling or typing.

On intent: first hover, focus, or click

At idle, add only a preconnect to frame.acme.io. On pointerenter or focus of the launcher, create the frame hidden. On click, show it.

Pros

  • Bytes are spent only by visitors who show interest: roughly 2 to 4% of page views.
  • Hover-to-click often gives a few hundred milliseconds of head start on desktop.

Cons

  • On touch screens there is no hover, so the first open waits for the frame. We cover that with instant feedback and a fast panel shell.

What we pick: Load on intent, with a preconnect at idle. The visitor who never clicks costs us 17 KB. The visitor who clicks sees feedback within one frame and a usable panel within about a second on 4G. Customers with a proactive message (a greeting that pops up after 30 seconds) switch to idle loading for the pages where that rule fires.

Panel loads
DeviceWidget

intent on phone: 16.5 KB during load, 16.5 KB if the visitor never clicks, 12 ms of host main-thread time during load, INP of a tap at 1.5 s about 90 ms, panel usable 900 ms after the click, CLS 0, about 8 TB of panel bytes a month.

Loader lab timeline from 0 to 8 seconds, with estimated numbers. The host page loads until its LCP at 2.4 seconds. The launcher loads from 0.3 to 0.6 seconds. The panel loads from 7.6 seconds for 1.4 seconds. A host tap happens at 1.5 seconds and the visitor clicks the launcher at 7.6 seconds. intent on phone: 16.5 KB during load, 16.5 KB if the visitor never clicks, 12 ms of host main-thread time during load, INP of a tap at 1.5 s about 90 ms, panel usable 900 ms after the click, CLS 0, about 8 TB of panel bytes a month.0 s2 s4 s6 s8 shostLCP 2.4 slauncherpanelhost tapvisitor clicks16.5 KB at load12 ms main threadINP 90 msopen in 900 msCLS 08 TB a monthif the visitor never clicks: 16.5 KB
Loading on intent spends the panel's bytes only for the few visitors who reach for it. The cost moves to the first open, which instant feedback and a preconnect hide. All numbers are rough estimates from a simple model, not measurements.

In the lab, compare the three strategies on a mid-range phone; its numbers are rough estimates from a simple model, not measurements. Eager loading looks fine in the bytes column only if you ignore that the visitor wanted to read the article, not chat. Watch the INP column: when the panel's startup lands during the visitor's first tap on the host page, that tap waits.

No layout shift, even for inline embeds

A floating launcher can't shift content: it is fixed, in the top layer, and takes no space in the page's flow. Inline embeds (a booking calendar inside an article, a comment thread under a post) are different. If our element starts at 0 px and grows to 600 px when the frame loads, everything below jumps, and the host's Cumulative Layout Shift (CLS) pays for it.

The fix is to reserve space. The config carries an expected height per embed type, and the host element gets min-height or an aspect-ratio before any network call. Resize messages after load only adjust within the reserved space or below the fold. The Poll widget guide works through the same problem for an inline embed.

Keeping the click cheap

ts
button.addEventListener("click", () => {
  if (state !== "closed") return toggle();
  setState("loading");                  // icon swap and spinner: a class change
  button.setAttribute("aria-expanded", "true");
  // Let the browser paint the feedback before we create a document.
  requestAnimationFrame(() => setTimeout(openPanel, 0));
});
Feedback first, then yield to the browser, then do the heavy work.

INP measures the time from an interaction to the next paint. The handler does only a class change and an attribute, so the paint happens within a frame. Creating the iframe, starting the handshake, and flushing queued commands happen after that paint. Where scheduler.yield() is available it is a cleaner way to yield; the requestAnimationFrame plus setTimeout pair works everywhere.

Check your understanding · Exercise 11

A customer reports their INP got worse after installing us, but only on Android phones. What are likely causes? Select all that apply.

Security, CSP, and privacy

Trust here is lopsided. The customer trusts us completely: our script runs with the same power as their own code, so it could read their cookies, their forms, and their visitors' keystrokes. We must not trust them in return: their page runs dozens of other third-party scripts, any of which could be compromised. A good design deserves the first kind of trust and survives the absence of the second.

Trust boundaries. Inside the host page, the customer's code trusts our launcher, which runs with full power on their page. An ad script on the same page can reach the launcher but can't read anything inside our frame on frame.acme.io, which holds the panel and the token. The launcher talks to the frame over the port. Below, a site called evil.example tries to frame our panel and is blocked by frame-ancestors.Host pageframe.acme.iocustomer JSad scriptlauncherfull power on their pagepanel + tokenprivatetrustsno readportevil.examplerefused by frame-ancestors
Trust flows one way. The customer lets us run on their page, so our launcher must deserve it. We trust nothing there, so the token stays on our origin, and frame-ancestors keeps other sites from framing the panel at all.

Protecting the host from us

  • A small, auditable host-realm footprint. About 15 KB of plain code in the host realm, with no dependencies and no eval. Customers' security teams can and do read it.
  • Supply chain discipline. Our CDN account, build pipeline, and deploy keys are as valuable to an attacker as a bank's, because one malicious loader reaches 20,000 sites. Require hardware keys for deploys, reproducible builds, and a second reviewer for any change to the loader. The Third-party scripts and supply chain lesson describes the attacks this prevents.
  • Sandbox our own frame. The panel iframe gets sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox". Leaving out allow-top-navigation means even a bug in our panel can't navigate the customer's page away. allow-same-origin is safe here because the frame is on our own origin, not theirs.
  • Minimal permissions. The iframe's allow attribute grants only what the panel uses, like clipboard-write. No camera, microphone, or geolocation unless a customer turns on a feature that needs them.
  • Read nothing we don't need. The loader reads its own tag, location.pathname for page rules, and html lang. It never reads forms, cookies, or the page's text.

Protecting the visitor from the host

  • Tokens never enter the host realm. Not in the config response, not in postMessage events, not in URLs. The frame URL carries only the app id, release, and parent origin. URLs end up in logs and Referer headers.
  • Events carry the minimum. Acme("on", "message") sends the message id and the time by default. Sending message text to the host page is an opt-in setting, because any compromised script on that page would receive it too.
  • Identity needs proof. userHash stops anyone from booting the widget as another user from the console.
  • Render agent and visitor content as text. Messages are inserted with textContent. Rich text from our help articles goes through a sanitizer, and links get rel="noopener noreferrer".

Clickjacking: who may frame our panel

Clickjacking is when an attacker's page loads a real site in an invisible iframe and lines it up under a fake button, so the victim's click lands on the real site. Our panel is designed to be framed, which makes it a target. An attacker could build evil.example, frame frame.acme.io/panel?app=app_7Kq2, set its opacity to 0, and trick visitors into clicking "Email me this transcript" or "Delete conversation".

The defense is the frame-ancestors CSP directive. The old X-Frame-Options header can only say DENY or SAMEORIGIN in modern browsers (its ALLOW-FROM form is obsolete), so it can't express a list of customer domains. frame-ancestors can, and our frame server sets it per request from the app id's allowed domains:

http
GET /3.14.2/panel?app=app_7Kq2&parentOrigin=https%3A%2F%2Fshop.example.com
Host: frame.acme.io

HTTP/2 200
Content-Security-Policy: frame-ancestors https://shop.example.com https://*.shop.example.com;
  default-src 'self'; script-src 'self'; connect-src 'self' https://api.acme.io wss://rt.acme.io
Cache-Control: no-cache
The panel's response for app_7Kq2. The browser refuses to render it inside any other site.

Because the header depends on the app id in the URL, the panel document can't be one shared cached file for all customers. It is a small HTML document per app, revalidated with an ETag, while its scripts and styles stay immutable and shared. When a customer adds a domain in the dashboard, the new list applies on the next panel load.

Some embeds must work on any site, like a YouTube player or an embedded post from X. They can't list ancestors, so they keep risky account actions out of the frame or hand them to the vendor's own site. The like button on an embedded X post, for example, opens a small window on the vendor's site (X calls these web intents), where the visitor sees the real address bar. The Clickjacking and iframes lesson covers the rest, including why the older JavaScript frame-busting tricks don't work.

Check your understanding · Exercise 12

Which measures protect the visitor if a third-party ad script on the host page is compromised? Select all that apply.

Living with the host's CSP and the browser's privacy rules

Two sets of rules sit between our code and the visitor, and we control neither. The host's Content Security Policy decides which of our requests the browser allows on their page. The browser's privacy protections decide what our frame may remember. Both fail silently: a blocked script or a missing cookie just looks like a widget that doesn't work.

Working inside a strict CSP

A CSP is a response header that lists where the page may load scripts, styles, frames, and connections from. The Content Security Policy lesson explains the directives. Our job is to need as few of them as possible and to document exactly which ones.

http
Content-Security-Policy:
  script-src 'nonce-{random}' 'strict-dynamic' https://widget.acme.io;
  connect-src 'self' https://api.acme.io;
  frame-src https://frame.acme.io;
  style-src 'self' 'nonce-{random}';
  trusted-types acme default;
  require-trusted-types-for 'script';
What a customer with a strict CSP adds for us: three of our domains, and nothing inline beyond the optional stub, which carries their nonce.
  • Reuse the page's nonce. The customer puts their nonce on our tag. The loader reads document.currentScript.nonce and sets it on the script and style elements it creates. With 'strict-dynamic', scripts created by a trusted script are trusted too, so the launcher bundle loads even if the host only lists the nonce. Browsers that understand 'strict-dynamic' ignore domain allowlists in script-src, so our domain there only matters for older browsers.
  • Nothing inline that needs 'unsafe-inline'. The launcher's styles go into one style element with the nonce. The launcher's icon is an SVG built with DOM calls, not an innerHTML string.
  • Trusted Types. Some hosts require Trusted Types, which blocks assigning plain strings to risky sinks such as script.src and innerHTML. The loader creates a policy named acme with trustedTypes.createPolicy that only accepts URLs on our CDN, and customers add acme to their trusted-types list.
  • The frame isn't bound by the host's CSP, except that frame-src must allow it. Inside, it has its own strict CSP that we control.
  • Detect and report blocks. The loader listens for securitypolicyviolation events whose blockedURI is on our domains and reports one csp_blocked event with the directive. Our dashboard then tells the customer exactly which line to add, instead of them opening a support ticket that says the bubble is gone.

Third-party cookies and partitioned storage

Our frame on frame.acme.io is a third-party context on shop.example.com. Browsers treat third parties differently from the site the visitor sees in the address bar:

  • Safari blocks third-party cookies by default. Firefox's Total Cookie Protection gives each top-level site its own cookie jar for third parties. Chrome still allows third-party cookies by default: Google dropped its plan to remove them in 2024 and its plan for a choice prompt in 2025. Chrome users can still block them in settings, and Incognito windows block them by default.
  • localStorage, IndexedDB, and the HTTP cache in third-party frames are partitioned by top-level site in all major browsers. Our frame on shop.example.com and our frame on news.example.org see two separate stores.
  • Safari may delete script-writable storage for sites the visitor hasn't interacted with for a while, so long-lived state in storage can vanish.

Partitioning is mostly good news for a support widget. A visitor's conversation with one shop should stay with that shop. What we lose is anything that relies on one global identity across sites, like a Disqus-style login that follows you from blog to blog.

Decision Where does the frame keep the visitor's session?

Classic third-party cookie

Set-Cookie: st=...; Secure; HttpOnly; SameSite=None.

Pros

  • HttpOnly keeps the token away from scripts, even ours.

Cons

  • Blocked in Safari, in Chrome's Incognito windows, and for Chrome users who turn blocking on. There, the widget forgets the visitor on every page load.

Partitioned cookie (CHIPS)

Add the Partitioned attribute. The cookie is stored in a jar keyed by the top-level site, so it is never shared across sites, and browsers that block cross-site cookies can still allow it.

Pros

  • Keeps HttpOnly.
  • Matches what we want: one session per customer site.

Cons

  • Support is recent and uneven. Chrome has it since version 114 and Firefox since 141 (2025), while Safari shipped it in 18.4, removed it in 18.5, and has only recently brought it back. Check current support tables, and keep a fallback for browsers without it.

Token in the frame's partitioned storage

Keep the token in memory and in the frame's localStorage, sent as an Authorization header.

Pros

  • Works in every browser that allows third-party storage at all, and it is already partitioned per site.

Cons

  • Readable by any script in our frame, so an XSS bug in the panel would expose it. Our frame's strict CSP limits that risk.

What we pick: A partitioned HttpOnly cookie where supported, with the frame-storage token as the fallback, both scoped to one customer site. If both fail (some strict privacy modes block all third-party storage), the panel keeps the session in memory for the page view and identified users re-establish it on each page through userHash. The Client storage options lesson covers quotas and eviction in more depth.

The Storage Access API is the other tool. A frame calls document.requestStorageAccess() after a user gesture, and the browser may grant it access to its unpartitioned cookies, sometimes after a prompt. It is supported in all major browsers, each with its own rules for when it grants access.

A comments embed where people log in once to the vendor's own site needs it. Our support widget doesn't, so we don't ask: a permission prompt on a store's page is a bad trade for a chat bubble.

Check your understanding · Exercise 13

A visitor chats with support on shop.example.com, then visits blog.example.org, which also uses our widget. What happens in a modern browser with default settings? Select all that apply.

Accessibility across the frame boundary

Inside the panel, accessibility is ordinary good practice: semantic HTML, labels, contrast, keyboard support. The hard parts are at the seams: the launcher's place in someone else's tab order, moving focus into a frame and back out, and announcing a new message when the panel is closed and the frame may not even exist.

The launcher

  • A real button with a label from the config: aria-label="Open support chat". An icon alone has no name.
  • aria-expanded reflects whether the panel is open, and aria-controls points at the panel's wrapper in our shadow root.
  • The unread badge is part of the name when it is non-zero: "Open support chat, 2 unread messages". A red dot alone tells a screen reader user nothing.
  • Tab order. We append our element at the end of body, so the launcher is the last tab stop on the page. That is the right place for a floating helper: it doesn't get in the way of the page's own content. We never use a positive tabindex.
  • Visible focus. Our own focus ring, with 3 to 1 contrast against both light and dark host backgrounds (a two-color ring works on any background).
  • Focus through the shadow boundary. We attach the shadow root with delegatesFocus: true, so a host script or skip link that calls focus() on acme-widget lands on the button inside. Host scripts reading document.activeElement see acme-widget, not our button, because focus is retargeted like events.

Moving focus into the frame and back

When the panel opens, keyboard and screen reader users must land inside it, on the first useful control. The host page can focus the iframe element, but it can't reach into a cross-origin document to focus a specific input. So focus is a two-step handoff over the port.

Sequence diagram of focus. The keyboard user presses Enter on the launcher. The launcher sends host open to the panel frame. The frame replies frame ready. The launcher calls focus on the iframe element. The frame focuses its composer, which the user now types in. The user presses Escape inside the frame. The frame sends frame close to the launcher, and the launcher calls focus on its button, so the user is back where they started.Keyboard userLauncherPanel frameEnter on launcherhost:openframe:readyiframe.focus()composer focusedEscapeframe:closebutton.focus()
The host can only focus the iframe element; the frame picks the control inside. Escape is only visible to the frame, so it must tell the launcher, which returns focus to its button.
  1. The launcher sends host:open and, when the frame answers frame:ready, shows the wrapper and calls iframe.focus(). The frame's document now has focus.
  2. The frame's window gets a focus event, and because the last command was host:open, it moves focus to its own first control: the composer if a conversation is open, otherwise the panel heading (with tabindex="-1") so screen readers read where they are.
  3. Escape inside the frame, or the panel's close button, sends frame:close with returnFocus: true. The launcher hides the panel and calls button.focus(), so the visitor is back where they started.

Should Tab be trapped inside the panel? A chat panel is non-modal: the visitor may want to copy an order number from the page while chatting. So no trap: Tab moves out of the frame into the host page naturally, and the launcher (now a close button) stays reachable.

A checkout or login overlay is different. It is modal, so we render its wrapper as a dialog in our shadow root and call showModal(), which makes the entire host page inert without us touching their DOM. The Focus management lesson and the Modal dialog guide cover both patterns.

Announcements across frames

A new reply arrives. If the panel is open, a polite live region inside the frame announces it, and screen readers do read live regions inside iframes. If the panel is closed, the frame may be hidden or not loaded, so the announcement must come from the host side.

The launcher keeps a visually hidden live region with role="status" in its shadow root. On frame:unread with an announce text, it updates the region: "New message from Sam at Example Shop". Only one side announces any given message, or the visitor hears it twice.

  • The iframe gets a title, like "Example Shop support chat", which screen readers use to name the frame.
  • Respect prefers-reduced-motion in both realms: no bouncing launcher, no sliding panel, just a fade or an instant change.
  • Never steal focus for proactive messages. A greeting that pops out after 30 seconds appears next to the launcher, is announced politely, and leaves focus where the visitor had it.

Check your understanding · Exercise 14

A screen reader user opens the panel with Enter on the launcher, reads a reply, and presses Escape. What should happen? Select all that apply.

Observability and testing on hostile pages

We can't open a customer's page in a debugger when something breaks. Most of what goes wrong only happens on one specific site, in one specific browser, next to one specific other script. So we need two things: telemetry that tells us which customer, version, and browser is failing without spying on the host, and a test suite that brings the worst host pages to us before release.

Errors without the host's noise or data

The naive approach is window.addEventListener("error", ...) in the launcher. That catches every error on the host page, most of which aren't ours, and reports their stack traces, which can contain their URLs and data. Instead we catch only our own:

  • Wrap our entry points. Every callback the launcher registers (event handlers, port messages, timers) goes through one guard(fn) wrapper with try and catch. An error inside reports to us and never reaches the host's onerror.
  • Inside the frame, global handlers are fine. The frame is our document, so error and unhandledrejection there are always ours.
  • Tag every report with app id, loader version, bundle version, protocol version, browser and version, and the step that failed (config, launcher, handshake, panel).
  • Collect no host page content. Send the host's origin, not the full URL: query strings carry emails and order ids. No DOM snapshots, no host console logs, no session replay of the host page.
  • Sample and cap. A loop that throws on every animation frame on one large site would otherwise send millions of reports. Deduplicate per page view and sample by the config's sampling.errors rate.

Besides errors, we track a few funnel metrics per release and per customer: loader executed, config loaded, launcher rendered, frame hello received, frame ready, and the time between them. A drop between "launcher rendered" and "frame hello" for one customer almost always means a CSP or proxy block; a drop for one browser across all customers means our bug. The Error tracking lesson covers grouping, source maps, and alerting.

A harness of hostile host pages

Unit tests cover our logic, but the bugs that hurt us come from host pages. So we build a gallery of deliberately terrible host pages, served from several test origins, and run end-to-end tests (Playwright across Chromium, Firefox, and WebKit) against each one on every release candidate.

Host pageWhat it doesWhat it catches
Global CSS chaos* { box-sizing: content-box !important; line-height: 3 }, button { all: unset }, iframe { width: 100% !important; border: 5px solid }Missing resets on :host and the iframe element
Tiny root fonthtml { font-size: 62.5% } and body { font-size: 22px }rem units in the launcher, inherited font size
Z-index warA fixed overlay at z-index: 2147483647 and transform on bodyTop layer use and the fixed-position fallback
Patched globalsOld jQuery and a Prototype-style Array.prototype extension, a fetch wrapper that drops bodies, Zone.jsReliance on patched natives in the host realm
Strict CSPNonce-only script-src, 'strict-dynamic', Trusted Types requiredMissing nonce on injected elements, Trusted Types violations
SPA routerA React app that changes routes with pushState and calls Acme("update")Page rules, duplicate launchers after navigation, teardown
Double installThe snippet twice, once through a tag managerDouble-load guard, customElements collisions
RTL and zoomdir="rtl", lang="ar", browser zoom at 200%Mirrored layout, reflow, focus visibility
Hostile messagesA script that posts fake frame:* and host:* messages from the page and from another iframeOrigin and source checks, the port-only rule

Each page runs the same scripted journey: the launcher renders with the expected size and position, has no CLS, opens with keyboard and mouse, completes a handshake, sends a message, closes with Escape and returns focus, and leaves no new globals behind (compare Object.keys(window) before and after). Visual snapshots of the launcher on each page catch styling leaks that assertions miss. And automated accessibility checks run inside both realms.

Check your understanding · Exercise 15

Which telemetry choices are right for code running on customer pages? Select all that apply.

Running it in the interview

This question rewards candidates who notice early that they are a guest. Interviewers listen for whether you see the host page as hostile, and whether your isolation choice is reasoned per part rather than one answer for everything. Here is one way to spend 60 minutes.

MinutesWhat to doWhat to draw or say
0 to 6RequirementsAsk: sensitive data? floating or inline? who installs? how many sites? strict CSP customers? State the budget: under 20 KB before interaction, zero CLS.
6 to 12The loaderDraw the snippet, the async loader, config, versioned bundle. Mention the stub queue in one sentence.
12 to 25IsolationCompare plain DOM, shadow DOM, iframe in a small table. Pick the hybrid and say why each half lands where it does.
25 to 35Architecture and messagingDraw host realm and frame realm side by side, the port between them, and the open-panel sequence with its timeout path.
35 to 45Versioning and performanceShort-cached loader, immutable bundles, rollout by customer cohort, kill switch in config, why no SRI on the loader. Load on intent.
45 to 55Deep dive the interviewer picksUsually CSS leaks, security (tokens, frame-ancestors), cookies, or accessibility across frames.
55 to 60Wrap upObservability tagged by customer and version, the hostile-page test harness, what you'd build next.

Draw the two realms first: a box for the host page with the launcher inside, a box for our frame with the panel inside, and one line between them labelled port. Almost every later question (CSS leaks, tokens, focus, cookies) is answered by pointing at which side of that line something lives on. Keep numbers on the board too: 2.5 KB loader, 17 KB before interaction, 180 KB on intent, 60-second config cache.

Follow-up questions to expect

Think first

"Why not put the whole widget in shadow DOM? It's lighter than an iframe."

Think first

"Our CDN goes down for 20 minutes. What happens to customer sites?"

Think first

"A bank's security team requires SRI on every third-party script. Can we support them?"

Think first

"The customer's site is a single-page app. What changes?"

Check your understanding · Exercise 16

Which statements would a strong candidate make in this interview? Select all that apply.

Summary

  • We are a guest on a hostile page: isolate in both directions, and keep secrets out of the host realm entirely.
  • The one-tag loader is async, tiny, and evergreen. It reads its tag, fetches config, and loads an immutable versioned bundle; a stub queue accepts calls before it arrives.
  • Pick isolation per part: a closed shadow root for the light, secret-free launcher; a cross-origin iframe for the heavy panel and anything sensitive, as Stripe Elements does for card fields.
  • Shadow DOM blocks selectors, not inheritance: reset with all: initial on :host, avoid rem, read only prefixed custom properties, and use the top layer instead of z-index wars.
  • Across frames, send with exact target origins, check origin and source, then switch to a private MessageChannel port with a typed, versioned protocol.
  • Short-cache the loader, cache bundles for a year, roll out by customer cohort, and keep the kill switch in config. SRI can't apply to an evergreen loader.
  • Spend the host's budget only on intent: about 17 KB until the visitor shows interest, feedback before work on click, zero layout shift.
  • Design for strict CSP (nonces, Trusted Types), partitioned storage, and blocked third-party cookies; protect our panel with per-customer frame-ancestors.
  • Hand focus into the frame and back over the port, announce from whichever side is visible, and test against a gallery of hostile host pages.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next