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
| Requirement | Target | Why |
|---|---|---|
| Never block the host | Loader is async and under 3 KB gzipped; under 15 ms of host main-thread work at load, no task over 10 ms | Customers measure their own Core Web Vitals. We must not show up in their reports. |
| Bytes before interaction | Under 20 KB until the visitor clicks | 98% of visitors never open the panel. They shouldn't pay for a 200 KB app. |
| No layout shift | Cumulative Layout Shift (CLS) added by us: 0 | A floating launcher must not push content. Inline embeds must reserve their space. |
| Isolation | Host CSS and JavaScript can't break us; we can't break the host | A broken widget on a store's checkout page loses the customer real money. |
| Security | Host scripts can never read visitor tokens or messages | The host page might be compromised. We are responsible for our visitors' data. |
| Safe releases | Roll out by percentage, roll back everywhere in under 5 minutes | One bad release would otherwise break every customer at once. |
| Accessibility | WCAG 2.2 AA inside our UI, across the iframe boundary | Keyboard 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 that1remis 10 px. Everyremsize 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 replaceswindow.fetchwith a wrapper that adds a header and drops our request body. - Our
.bubbleclass 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
- 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.
- 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.
- 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.
- 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.
- 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.
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 launcher is on every page view, so it must be tiny and appear fast, but it holds no secrets: a button and a number. The panel is opened by about 2% of visitors, so it can be large and load late, but it shows private messages and accepts typed input, so it needs the strongest isolation.
That split is the core of the design we'll build: a light launcher in the host page (in a shadow root) and the heavy panel in an iframe on our own origin.
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?
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.
<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>
What the loader does, in order
(() => {
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
})();
- Read
document.currentScriptfirst. It points at our tag only while the script runs for the first time; inside a callback it isnull. - 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.
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?
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.
shadow DOM: 4 broken, 1 risky, 0 safe.
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
| Threat | Plain DOM + prefix | Shadow DOM | Cross-origin iframe |
|---|---|---|---|
Host selectors (button, *, .card) | Leak in | Blocked | Blocked |
Inherited properties (font-size, color) | Leak in | Leak in (reset at :host) | Blocked |
| CSS custom properties | Leak in | Leak in | Blocked |
rem units | Host's root size | Host's root size | Our own root |
| Our CSS leaking out | Possible | Blocked | Blocked |
Patched globals (fetch, Array.prototype) | Shared | Shared | Separate realm |
| Host reading visitor input | Yes | Yes | No |
| Host listening to our events | Yes | Yes, retargeted to the host element | No |
| Focus and tab order | Natural | Natural | Crosses a boundary |
| Host CSP applies to us | Yes | Yes | Only 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.
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.
: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);
}
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: initialresets every property on the host element to its initial value, which cuts inheritance from the page. It does not reset custom properties (ordirection), so a host's--primarystill flows in. That is fine as long as we only read our own prefixed names like--acme-accent.- Never use
remin the launcher.remis relative to the roothtmlelement, which the host owns. A host withhtml { font-size: 62.5% }makes1remequal to 10 px instead of 16 px. Usepx, oremrelative to the14pxwe set on.root. Inside the iframeremis 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 matchacme-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.
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 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.
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.
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);
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.prototypeand friends. Prototype once addedArray.prototype.toJSON, which madeJSON.stringifyencode arrays as strings. Afor...inover an array suddenly lists extra keys. - Wrapped
fetchandXMLHttpRequest. Monitoring tools, ad blockers, and app code wrap them to log, add headers, or mock. A careless wrapper drops thebody, breakskeepalive, 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
PromiseorObject.assignpolyfill 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.definethrows 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?
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.
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);
}
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 likewindow.__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.PromiseorArray.prototype. - No patching host APIs. To detect route changes in single-page apps, listen for
popstateand use the Navigation API where it exists, or let developers callAcme("update"). Patchinghistory.pushStatecollides 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 onwindow(forresizeorpagehide), 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?
No. The iframe is a separate document on our origin, with its own realm and its own globals. Nothing the host does to window.XMLHttpRequest or window.fetch exists inside it.
Only the host-realm code (loader and launcher) sees host patches. That is exactly why we keep that code small.
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
- 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. - Always check who sent it. Check
event.originagainst the expected origin andevent.sourceagainst the exact window (iframe.contentWindoworwindow.parent). Origin alone isn't enough when the host page has several of our frames. - 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.
source check. After host:connect, both sides talk only on the private port, so the ad's messages can never reach the conversation.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);
});
}
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
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 };
- 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.
postMessagecopies 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.
| Piece | Runs in | Size (gzip) | Responsibility |
|---|---|---|---|
| Snippet and stub | Host page, inline | Under 300 bytes | Queue early API calls, inject the loader. |
| Loader | Host realm, widget.acme.io/v1/loader.js | About 2.5 KB | Read the tag, fetch config, check page rules and kill switch, load the launcher bundle. |
| Launcher | Closed shadow root in the host page | About 12 KB | Draw the bubble and badge, own the public Acme() API, create the panel frame, run the port, return focus. |
| Panel app | Cross-origin iframe on frame.acme.io | About 180 KB | Home, articles, conversation, composer, uploads. Holds the session token and the real-time connection. |
| Config API | Our edge, cached 60 s | About 2 KB JSON | Per-customer settings: theme, texts, page rules, allowed domains, version channel, kill switch. |
| Chat API and socket | Our servers | Not shipped | Messages, unread counts, presence. Only the frame talks to it with credentials. |
| Error and metrics collector | Our servers | Not shipped | Errors 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.
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
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.
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.
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
};
| Data | Lives in | Why there |
|---|---|---|
| Widget config | CDN cache, then launcher memory, copied to the frame in host:connect | Public, the same for every visitor of a site. Cached at the edge for 60 s. |
| Anonymous visitor id | Host page first-party localStorage, key acme:vid:app_7Kq2 | Survives across pages and visits on that site, isn't blocked as a third-party cookie, and isn't secret. |
| Session token | Frame memory and frame storage on frame.acme.io | Authenticates the visitor to the chat API. The host realm must never hold it. |
| Conversations and messages | Frame: a server-state cache, refreshed by the socket | Private. Rendered only inside the frame. |
| Unread count | Launcher memory | Shown on the badge. A number is safe to hold in the host realm. |
| Panel open or closed | Host sessionStorage | On multi-page sites, a visitor mid-chat expects the panel to stay open on the next page. |
| Queued API calls | Launcher memory | Flushed to the frame when the port opens. |
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.
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
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
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)
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?
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.
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 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"followsprefers-color-scheme, which works inside the frame. Hosts with their own dark mode toggle callAcme("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 langattribute when set toauto. Right-to-left languages flip the launcher to the other side and setdir="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?
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.
| Resource | Example URL | Cache-Control | Changes |
|---|---|---|---|
| Loader | /v1/loader.js | public, max-age=300 | Rarely: only when the loader logic itself changes. |
| Config | /v1/apps/app_7Kq2/widget-config | public, max-age=60, stale-while-revalidate=60 | When the customer edits settings, or we move a release or flip a kill switch. |
| Launcher bundle | /v1/3.14.2/launcher.8c1e.js | public, max-age=31536000, immutable | Never: a new release is a new URL. |
| Panel document | frame.acme.io/3.14.2/panel | no-cache (revalidate with an ETag) | Per release. Its own scripts and styles are hashed and immutable. |
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.
- Our own sites and a test harness of hostile pages (see the testing chapter).
- 1% of customers for a few hours, then 10%, then 50%, then 100% over two to three days.
- 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.
| Budget | Target | How we meet it |
|---|---|---|
| Bytes before the visitor interacts | Under 20 KB gzipped | Loader 2.5 KB, config 2 KB, launcher 12 KB. The 180 KB panel loads on intent. |
| Host main-thread time at load | Under 15 ms total, no task over 10 ms | No framework in the launcher: a few hundred lines of plain DOM code. |
| Layout shift we cause | 0 | The launcher is fixed and in the top layer. Inline embeds reserve space. |
| Click to feedback | Under 50 ms | The click handler only swaps the launcher's icon; frame work starts after the next paint. |
| Click to usable panel | Under 1 s on 4G for a first open, under 200 ms after a warm-up | Preconnect and prefetch on intent; render the panel shell from the frame's HTML. |
Decision When do we load the 180 KB panel?
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.
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.
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
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));
});
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.
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 outallow-top-navigationmeans even a bug in our panel can't navigate the customer's page away.allow-same-originis safe here because the frame is on our own origin, not theirs. - Minimal permissions. The iframe's
allowattribute grants only what the panel uses, likeclipboard-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.pathnamefor page rules, andhtml 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
postMessageevents, not in URLs. The frame URL carries only the app id, release, and parent origin. URLs end up in logs andRefererheaders. - 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.
userHashstops 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 getrel="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:
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
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.
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';
- Reuse the page's nonce. The customer puts their nonce on our tag. The loader reads
document.currentScript.nonceand sets it on the script andstyleelements 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 inscript-src, so our domain there only matters for older browsers. - Nothing inline that needs
'unsafe-inline'. The launcher's styles go into onestyleelement with the nonce. The launcher's icon is an SVG built with DOM calls, not aninnerHTMLstring. - Trusted Types. Some hosts require Trusted Types, which blocks assigning plain strings to risky sinks such as
script.srcandinnerHTML. The loader creates a policy namedacmewithtrustedTypes.createPolicythat only accepts URLs on our CDN, and customers addacmeto theirtrusted-typeslist. - The frame isn't bound by the host's CSP, except that
frame-srcmust allow it. Inside, it has its own strict CSP that we control. - Detect and report blocks. The loader listens for
securitypolicyviolationevents whoseblockedURIis on our domains and reports onecsp_blockedevent 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 onshop.example.comand our frame onnews.example.orgsee 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?
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
buttonwith a label from the config:aria-label="Open support chat". An icon alone has no name. aria-expandedreflects whether the panel is open, andaria-controlspoints 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 positivetabindex. - 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 callsfocus()onacme-widgetlands on the button inside. Host scripts readingdocument.activeElementseeacme-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.
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.- The launcher sends
host:openand, when the frame answersframe:ready, shows the wrapper and callsiframe.focus(). The frame's document now has focus. - The frame's
windowgets afocusevent, and because the last command washost:open, it moves focus to its own first control: the composer if a conversation is open, otherwise the panel heading (withtabindex="-1") so screen readers read where they are. - Escape inside the frame, or the panel's close button, sends
frame:closewithreturnFocus: true. The launcher hides the panel and callsbutton.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-motionin 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 withtryandcatch. An error inside reports to us and never reaches the host'sonerror. - Inside the frame, global handlers are fine. The frame is our document, so
errorandunhandledrejectionthere 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.errorsrate.
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 page | What it does | What 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 font | html { font-size: 62.5% } and body { font-size: 22px } | rem units in the launcher, inherited font size |
| Z-index war | A fixed overlay at z-index: 2147483647 and transform on body | Top layer use and the fixed-position fallback |
| Patched globals | Old jQuery and a Prototype-style Array.prototype extension, a fetch wrapper that drops bodies, Zone.js | Reliance on patched natives in the host realm |
| Strict CSP | Nonce-only script-src, 'strict-dynamic', Trusted Types required | Missing nonce on injected elements, Trusted Types violations |
| SPA router | A React app that changes routes with pushState and calls Acme("update") | Page rules, duplicate launchers after navigation, teardown |
| Double install | The snippet twice, once through a tag manager | Double-load guard, customElements collisions |
| RTL and zoom | dir="rtl", lang="ar", browser zoom at 200% | Mirrored layout, reflow, focus visibility |
| Hostile messages | A script that posts fake frame:* and host:* messages from the page and from another iframe | Origin 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Requirements | Ask: 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 12 | The loader | Draw the snippet, the async loader, config, versioned bundle. Mention the stub queue in one sentence. |
| 12 to 25 | Isolation | Compare plain DOM, shadow DOM, iframe in a small table. Pick the hybrid and say why each half lands where it does. |
| 25 to 35 | Architecture and messaging | Draw host realm and frame realm side by side, the port between them, and the open-panel sequence with its timeout path. |
| 35 to 45 | Versioning and performance | Short-cached loader, immutable bundles, rollout by customer cohort, kill switch in config, why no SRI on the loader. Load on intent. |
| 45 to 55 | Deep dive the interviewer picks | Usually CSS leaks, security (tokens, frame-ancestors), cookies, or accessibility across frames. |
| 55 to 60 | Wrap up | Observability 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."
Shadow DOM isolates selectors, but not inherited properties, custom properties, rem, or JavaScript globals, and it is not a security boundary: host scripts can read our text and our events. The panel shows private conversations and holds a session token, so it needs a separate origin.
For the launcher, which holds nothing secret and must be on every page cheaply, shadow DOM is the right tool, and that is where we use it.
Think first
"Our CDN goes down for 20 minutes. What happens to customer sites?"
Nothing visible beyond a missing bubble. The loader is async, so the host page never waits for it. If the loader itself can't load, the stub just keeps queueing calls into an array that is never read. If the config or bundle fails, the loader's catch swallows it.
We'd see it as a drop in the loader-executed funnel step, and serve the loader and bundles from a second CDN through DNS failover for next time.
Think first
"A bank's security team requires SRI on every third-party script. Can we support them?"
Not on an evergreen URL: the hash would break with our next release and the widget would vanish. We offer pinned, versioned loader URLs with published hashes. The bank updates on its own schedule; we still apply SRI to the second hop from the config, and the kill switch keeps working because it lives in config data.
We'd also tell them the cost: security fixes reach them only when they update, so we publish advisories and keep pinned versions supported for a fixed window, say 12 months.
Think first
"The customer's site is a single-page app. What changes?"
The loader runs once, but page rules depend on the route. We listen for popstate, use the Navigation API's navigate event where available, and document Acme("update") for the router to call. We don't patch history.pushState.
Logout without a reload must call Acme("shutdown") to clear the session, or the next person on a shared computer sees the previous user's chat. And the launcher must survive the app re-rendering body: if our element is removed, a cheap MutationObserver on body's direct children re-attaches it.
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: initialon:host, avoidrem, read only prefixed custom properties, and use the top layer instead of z-index wars. - Across frames, send with exact target origins, check
originandsource, 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.
References
- MDN: Using shadow DOM What a shadow root encapsulates, and how styles and inheritance behave.
- MDN: Window.postMessage() Target origins and the security notes on checking the sender.
- MDN: MessageChannel Private ports between documents.
- MDN: Popover API Putting elements in the top layer without z-index.
- MDN: CSP frame-ancestors Controlling which sites may frame a page.
- MDN: Subresource Integrity How integrity hashes work and why they need fixed bytes.
- MDN: Cookies Having Independent Partitioned State (CHIPS) The Partitioned cookie attribute for third-party contexts.
- MDN: Storage Access API How embedded content asks for unpartitioned cookies.
- web.dev: Efficiently load third-party JavaScript Async loading, preconnect, and lazy loading from the host's side.
- Stripe docs: Integration security guide Why Elements fields are iframes and how that affects PCI scope.
Concepts behind it
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Web components and shadow DOMThe browser's own component model: custom HTML tags with their own lifecycle, a shadow tree that keeps their markup and styles private, and slots for the page's content, so one component works in React, Vue, Angular, or plain HTML.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Error trackingCatching the errors that happen in users' browsers, decoding minified stacks, grouping thousands of reports into a few issues, and alerting when a release makes things worse: how you learn the app is broken before support does.