Frontend System Design
31 min read

Multi-Tenant Pluggable Widget Framework

Designing a frontend framework that hosts third-party extensions—dynamically loaded at runtime based on tenant configurations. This article covers the architectural decisions behind systems like VS Code extensions, Figma plugins, and Shopify embedded apps: module loading strategies (Webpack Module Federation vs SystemJS), sandboxing techniques (iframe, Shadow DOM, Web Workers, WASM), manifest and registry design, the host SDK API contract, and multi-tenant orchestration that resolves widget implementations per user or organization.

Widget B (Module Federation)

Widget A (iframe)

Host Application

Contribution Points

tenant: acme

Resolve widget IDs

Return URLs + manifests

Load remote

Load remote

postMessage

Direct API

Tenant Config Service

Widget Registry

Widget Loader

Host SDK

Slot: Header

Slot: Sidebar

Slot: Content

Third-Party Code

Shadow DOM

Trusted Code

Widget framework architecture: tenant configuration determines which widgets load; registry provides manifests and URLs; loader mounts widgets into contribution points; SDK mediates communication.

A pluggable widget framework is a microkernel architecture: minimal core system + dynamically loaded extensions. The core decisions are:

  1. Loading strategy: How does remote code enter the host?

    • Module Federation: optimal sharing, webpack-only, trusted code
    • SystemJS/import maps: standards-based, flexible, moderate performance
    • iframe: complete isolation, highest security, communication overhead
  2. Isolation boundary: How much can widgets affect the host?

    • Same-origin (Module Federation): full DOM access, shared memory—trust required
    • iframe sandbox: separate browsing context, postMessage only
    • Shadow DOM: CSS isolation only, same JS context
    • WASM sandbox (Figma model): isolated JS engine, strongest security
  3. Extension points: Where can widgets appear?

    • Contribution points: named slots in the UI that widgets register for
    • Declarative via manifest: host reads manifest, renders widget in appropriate slot
  4. Multi-tenancy: How do different tenants get different widgets?

    • Tenant config service: maps tenant ID → enabled widget IDs
    • Registry resolution: widget ID → implementation URL
    • Feature flags: toggle widgets per tenant without redeployment

Key numbers:

AspectModule FederationiframeWASM Sandbox
Load overhead~50ms (shared deps)~100-200ms (full context)~300-500ms (engine init)
Memory per widgetShared with hostSeparate heap (~10-50MB)Separate (~5-20MB)
Communication latencyMicroseconds~1-5ms (postMessage)~0.5-2ms (WASM boundary)
SecuritySame origin trustStrong isolationStrongest isolation

Extensibility without deployments: Third parties add features without modifying host code. VS Code has 40,000+ extensions; the core team didn’t build them.

Multi-tenant customization: Enterprise SaaS customers demand unique workflows. Widget A for Tenant X, Widget B for Tenant Y—without forking the codebase.

Ecosystem growth: A platform with extension capabilities attracts developers who build features you never considered.

Main thread budget: Loading and initializing widgets competes with the host application. A poorly designed loader blocks the UI during startup.

Memory limits: Each iframe creates a separate JavaScript heap. Loading 10 widgets as iframes can consume 100-500MB—problematic on mobile devices (50-100MB practical limit).

Network overhead: Fetching widget bundles from different origins means separate HTTP connections, no shared caching of common dependencies.

Untrusted code execution: Third-party developers may write malicious or buggy code. Without isolation, a widget can:

  • Access user credentials stored in localStorage
  • Modify any DOM element, including login forms
  • Make requests to any origin (CSRF)
  • Crash the host application

Defense in depth:

ThreatMitigation
DOM manipulationiframe sandbox, separate browsing context
CSS pollutionShadow DOM, iframe
Data exfiltrationCSP, sandbox restrictions
CPU exhaustionWeb Worker isolation, timeout enforcement
Memory leaksWidget lifecycle management, unload on deactivation
FactorSmall ScaleLarge Scale
Widgets loaded1-550-100
Tenants1-1010,000+
Widget registry size10-20 widgets1,000+ widgets
Update frequencyWeeklyContinuous
Widget developersInternal teamThird-party ecosystem

Remote B

Remote A

Host (Shell)

import('remoteA/WidgetA')

import('remoteB/WidgetB')

Singleton shared

Singleton shared

Host Container

Shared React

Widget A Bundle

Exposed: WidgetA

Widget B Bundle

Exposed: WidgetB

Module Federation: host and remotes share dependencies (React) via singleton negotiation; widgets load as federated modules.

How it works:

Module Federation (Webpack 5+) allows separate webpack builds to share code at runtime. The host declares remotes (external builds to consume) and shared dependencies (libraries to deduplicate).

host/webpack.config.js
5 collapsed lines
const { ModuleFederationPlugin } = require("webpack").container
module.exports = {
// ... other config
plugins: [
new ModuleFederationPlugin({
name: "host",
remotes: {
// Static remotes (known at build time)
widgetA: "widgetA@https://cdn.example.com/widget-a/remoteEntry.js",
// Dynamic remotes resolved at runtime
widgetB: `promise new Promise(resolve => {
const remoteUrl = window.__WIDGET_REGISTRY__["widgetB"];
const script = document.createElement("script");
script.src = remoteUrl;
script.onload = () => resolve(window.widgetB);
document.head.appendChild(script);
})`,
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
2 collapsed lines
],
}
widget-a/webpack.config.js
5 collapsed lines
const { ModuleFederationPlugin } = require("webpack").container
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "widgetA",
filename: "remoteEntry.js",
exposes: {
"./Widget": "./src/Widget", // What this remote offers
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
}

Runtime loading:

host/src/WidgetLoader.tsx
3 collapsed lines
import React, { Suspense, lazy } from "react";
// Dynamic import of federated module
const WidgetA = lazy(() => import("widgetA/Widget"));
export function WidgetSlot({ widgetId }: { widgetId: string }) {
// In production: resolve widget ID to remote dynamically
return (
<Suspense fallback={<WidgetSkeleton />}>
<WidgetA />
</Suspense>
);
}

Dependency sharing mechanics:

  1. Host starts, loads remoteEntry.js for each remote
  2. Remote entry registers available modules and shared dependencies
  3. When host imports a remote module, federation checks shared scope
  4. If compatible version exists, reuse; otherwise, load remote’s bundled copy
  5. singleton: true ensures only one instance (critical for React context)

Best for:

  • Trusted first-party widgets (internal teams)
  • Micro-frontend architectures with shared design systems
  • Performance-critical scenarios where bundle size matters

Device/network profile:

  • Works well on: Desktop, fast networks, trusted widget sources
  • Struggles on: Untrusted third-party code (no isolation), slow networks (remote entry overhead)

Implementation complexity:

AspectEffort
Initial setupHigh (webpack config complexity)
Adding new widgetsLow (configure remote URL)
Version managementMedium (shared version negotiation)
SecurityLow (same-origin trust model)

Real-world example:

Shopify’s Hydrogen uses Module Federation for composable storefronts. Multiple teams build sections independently; the storefront shell loads them at runtime with shared Remix/React dependencies.

Trade-offs:

  • Shared dependencies reduce duplicate downloads (30-50% smaller total payload)
  • Same JavaScript context—widgets can access host globals
  • Webpack-only (no native Vite support as of 2025; use vite-plugin-federation)
  • Version mismatches cause runtime errors

Security consideration: Module Federation provides no isolation. Widgets execute in the same origin with full DOM access. Use only for trusted code or combine with additional sandboxing.

iframe (sandbox)

Host Application

Create iframe

sandbox='allow-scripts'

postMessage

API Response

SDK call

Host Page

postMessage Handler

Widget Code

Widget SDK Shim

iframe sandbox: widget runs in isolated browsing context; communication via postMessage only.

How it works:

The <iframe sandbox> attribute creates a separate browsing context with restricted capabilities. By default, a sandboxed iframe:

  • Cannot execute scripts
  • Cannot submit forms
  • Cannot access parent DOM
  • Cannot navigate the top-level browsing context
  • Cannot use plugins

Capabilities are granted explicitly:

<iframe
src="https://widget.example.com/widget-a"
sandbox="allow-scripts allow-same-origin"
allow="clipboard-read; clipboard-write"
csp="script-src 'self'; style-src 'self' 'unsafe-inline'"
></iframe>

Sandbox attribute options:

AttributeGrants
allow-scriptsJavaScript execution
allow-same-originTreats content as same origin (dangerous with allow-scripts)
allow-formsForm submission
allow-popupsWindow.open(), target=“_blank”
allow-modalsalert(), confirm(), prompt()
allow-top-navigationNavigate parent window

Security warning: Never combine allow-scripts and allow-same-origin for untrusted content. The iframe can remove its own sandbox attribute and escape.

Communication via postMessage:

host/src/WidgetBridge.ts
interface WidgetMessage {
type: string
requestId: string
payload: unknown
}
class WidgetBridge {
private iframe: HTMLIFrameElement
private pendingRequests = new Map<string, { resolve: Function; reject: Function }>()
private trustedOrigin: string
constructor(iframe: HTMLIFrameElement, trustedOrigin: string) {
this.iframe = iframe
this.trustedOrigin = trustedOrigin
window.addEventListener("message", this.handleMessage)
}
private handleMessage = (event: MessageEvent<WidgetMessage>) => {
// CRITICAL: Always validate origin
if (event.origin !== this.trustedOrigin) return
const { type, requestId, payload } = event.data
if (type === "API_RESPONSE" && this.pendingRequests.has(requestId)) {
const { resolve } = this.pendingRequests.get(requestId)!
this.pendingRequests.delete(requestId)
resolve(payload)
}
}
async call(method: string, args: unknown[]): Promise<unknown> {
const requestId = crypto.randomUUID()
return new Promise((resolve, reject) => {
this.pendingRequests.set(requestId, { resolve, reject })
this.iframe.contentWindow?.postMessage(
{ type: "API_CALL", requestId, method, args },
this.trustedOrigin, // CRITICAL: Specify target origin
)
// Timeout to prevent hanging requests
setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId)
reject(new Error(`Widget call timed out: ${method}`))
}
}, 5000)
})
}
}
widget/src/sdk-shim.ts
// Inside the iframe: SDK shim that talks to host
const hostSDK = {
async showNotification(message: string): Promise<void> {
return callHost("showNotification", [message])
},
async getData(key: string): Promise<unknown> {
return callHost("getData", [key])
},
}
function callHost(method: string, args: unknown[]): Promise<unknown> {
const requestId = crypto.randomUUID()
return new Promise((resolve) => {
const handler = (event: MessageEvent) => {
if (event.data.requestId === requestId && event.data.type === "API_RESPONSE") {
window.removeEventListener("message", handler)
resolve(event.data.payload)
}
}
window.addEventListener("message", handler)
window.parent.postMessage(
{ type: "API_CALL", requestId, method, args },
"*", // Widget doesn't know host origin; host validates on receive
)
})
}
// Expose SDK to widget code
;(window as any).hostSDK = hostSDK

Best for:

  • Untrusted third-party widgets
  • Payment forms, authentication widgets (PCI/SOC2 compliance)
  • Widgets that need strong isolation guarantees

Device/network profile:

  • Works well on: All devices, any network (self-contained)
  • Struggles on: Mobile (memory overhead), many simultaneous widgets

Implementation complexity:

AspectEffort
Initial setupMedium (iframe + postMessage)
Adding new widgetsLow (point to URL)
Performance tuningHigh (message serialization overhead)
SecurityLow (browser provides isolation)

Real-world example:

Figma plugins use iframe for UI rendering combined with a WASM sandbox for document manipulation. The iframe displays the plugin UI; a separate QuickJS WASM sandbox runs the logic that reads/writes the Figma document.

Trade-offs:

  • Strongest browser-native isolation
  • Each iframe creates separate JS heap (10-50MB overhead)
  • postMessage serialization adds 1-5ms latency per call
  • No shared dependencies (duplicate React, etc.)
  • Cross-origin communication requires careful origin validation

Shadow Root (closed)

Host DOM

attachShadow({mode:'closed'})

Document

Shadow DOM

Widget Styles

Widget Template

Shadow DOM: CSS isolation via encapsulated DOM tree; JavaScript shares host context.

How it works:

Shadow DOM creates an encapsulated DOM subtree with its own style scope. Styles inside don’t leak out; external styles don’t leak in.

host/src/WidgetContainer.ts
class WidgetContainer extends HTMLElement {
private shadow: ShadowRoot
constructor() {
super()
// 'closed' prevents external access to shadow root
this.shadow = this.attachShadow({ mode: "closed" })
}
async loadWidget(widgetUrl: string) {
// Fetch widget bundle
const response = await fetch(widgetUrl)
const widgetCode = await response.text()
// Create isolated style scope
const style = document.createElement("style")
style.textContent = await this.fetchWidgetStyles(widgetUrl)
// Create widget container
const container = document.createElement("div")
container.className = "widget-root"
this.shadow.appendChild(style)
this.shadow.appendChild(container)
// Execute widget code in this context
// WARNING: No JS isolation—widget code runs in host context
const widgetModule = new Function("container", "hostSDK", widgetCode)
widgetModule(container, window.hostSDK)
}
}
customElements.define("widget-container", WidgetContainer)

CSS isolation guarantees:

  • Widget styles scoped to shadow root
  • Host styles don’t affect widget (unless using CSS custom properties)
  • Widget cannot style host elements
  • ::part() and CSS variables allow controlled customization
/* Widget internal styles (inside shadow DOM) */
.button {
/* This .button won't conflict with host's .button */
background: var(--widget-primary, blue); /* Customizable via CSS var */
}
/* Host can customize via CSS custom properties */
widget-container {
--widget-primary: red; /* Passes through shadow boundary */
}

Best for:

  • Design system components with style encapsulation
  • Trusted widgets that need CSS isolation but not JS isolation
  • Web Components-based architectures

Device/network profile:

  • Works well on: All modern browsers, any device
  • Struggles on: Scenarios requiring JavaScript isolation

Implementation complexity:

AspectEffort
Initial setupLow (native browser API)
CSS isolationLow (automatic)
JS isolationNone (same context)
Framework integrationMedium (React/Vue wrappers needed)

Trade-offs:

  • Lightweight: No separate heap or context
  • CSS isolation is complete
  • No JavaScript isolation—widgets can access globals, DOM, etc.
  • Requires combining with other techniques for untrusted code

Plugin Process

Host Application

WASM Sandbox

Structured API

WASM boundary calls

postMessage

Host Main Thread

Plugin API

QuickJS Engine

Plugin Code

Plugin UI (iframe)

WASM sandbox: JavaScript engine (QuickJS) compiled to WebAssembly runs plugin logic; separate iframe renders UI.

How it works:

Figma’s approach: compile a JavaScript engine (QuickJS) to WebAssembly. Plugin code runs inside this embedded JS engine—completely isolated from the host JavaScript context.

Why QuickJS + WASM?

  1. No eval(): Running untrusted JS usually requires eval() or new Function(), which CSP blocks. QuickJS interprets JS without these.
  2. Capability-based security: WASM provides no default I/O. Plugin only accesses what the host explicitly provides via API.
  3. Deterministic execution: Same code, same inputs → same outputs. Useful for collaborative features.
host/src/WasmSandbox.ts
5 collapsed lines
import { newQuickJSWASMModule, QuickJSWASMModule, QuickJSContext } from "quickjs-emscripten"
class PluginSandbox {
private vm: QuickJSWASMModule
private context: QuickJSContext
async initialize() {
this.vm = await newQuickJSWASMModule()
this.context = this.vm.newContext()
// Expose host API to sandbox
this.exposeHostAPI()
}
private exposeHostAPI() {
const hostAPI = this.context.newObject()
// Expose showNotification
const showNotification = this.context.newFunction("showNotification", (msgHandle) => {
const message = this.context.getString(msgHandle)
// Call actual host notification system
window.hostNotifications.show(message)
return this.context.undefined
})
this.context.setProp(hostAPI, "showNotification", showNotification)
this.context.setProp(this.context.global, "figma", hostAPI)
showNotification.dispose()
hostAPI.dispose()
}
async executePlugin(code: string): Promise<void> {
const result = this.context.evalCode(code)
11 collapsed lines
if (result.error) {
const error = this.context.dump(result.error)
result.error.dispose()
throw new Error(`Plugin error: ${JSON.stringify(error)}`)
}
result.value.dispose()
}
dispose() {
this.context.dispose()
this.vm.dispose()
}
}

Two-part plugin architecture (Figma):

  1. UI iframe: Renders plugin UI using HTML/CSS. Communicates with logic via postMessage.
  2. WASM sandbox: Runs plugin logic. Has access to Figma document API. No DOM access.

This separation prevents plugins from both manipulating the document AND accessing arbitrary DOM.

Best for:

  • Highest security requirements
  • Platforms where plugins manipulate sensitive documents
  • When you cannot trust third-party code at all

Device/network profile:

  • Works well on: Desktop, modern mobile (WASM support universal since 2017)
  • Struggles on: Low-memory devices (WASM instance overhead), very complex plugins (QuickJS slower than V8)

Implementation complexity:

AspectEffort
Initial setupVery High (custom sandbox)
API surface designHigh (every API must be explicitly exposed)
Performance tuningHigh (WASM boundary crossing)
SecurityLow (isolation is architectural)

Trade-offs:

  • Strongest isolation (capability-based security)
  • 300-500ms initialization overhead (loading WASM module)
  • QuickJS performance ~10-30x slower than V8
  • Complex to implement; consider using existing libraries (quickjs-emscripten)
  • Plugin capabilities are exactly what you expose—no accidental surface
FactorModule Federationiframe SandboxShadow DOMWASM Sandbox
JS IsolationNoneStrongNoneStrongest
CSS IsolationNoneCompleteCompleteN/A (no DOM)
Shared dependenciesYesNoManualNo
Load timeFast (50ms)Medium (100-200ms)Fast (10ms)Slow (300-500ms)
Memory per widgetShared10-50MBShared5-20MB
CommunicationDirectpostMessage (1-5ms)DirectWASM boundary (~1ms)
Trust modelSame-originUntrustedSame-originUntrusted
Browser supportWebpack onlyUniversalUniversalUniversal

Yes (internal teams)

No (external developers)

No

Yes

High (financial, PII)

Medium

Yes

No

Need widget framework

Trust third-party code?

Need CSS isolation?

Security requirements?

Module Federation

Module Federation + Shadow DOM

WASM Sandbox

iframe Sandbox

Need plugin UI?

WASM + iframe (Figma model)

WASM only

Decision tree for selecting widget isolation strategy based on trust level and security requirements.

Every widget declares its capabilities, entry points, and requirements via a manifest file. This enables the host to:

  • Validate widget compatibility before loading
  • Render UI contributions without loading widget code
  • Enforce permission boundaries
widget-a/manifest.json
{
"$schema": "https://widgets.example.com/manifest.schema.json",
"id": "com.example.video-player",
"name": "Premium Video Player",
"version": "2.1.0",
"description": "HD video player with adaptive streaming",
"author": {
"name": "Example Corp",
"email": "widgets@example.com",
"url": "https://example.com"
},
"host": {
"minVersion": "3.0.0",
"maxVersion": "4.x"
},
"main": "./dist/widget.js",
"styles": "./dist/widget.css",
"remoteEntry": "./dist/remoteEntry.js",
"contributes": {
"slots": [
{
"id": "content-area",
"component": "VideoPlayer",
"props": {
"defaultQuality": "auto"
}
}
],
"commands": [
{
"id": "video-player.play",
"title": "Play Video"
},
{
"id": "video-player.pause",
"title": "Pause Video"
}
],
"settings": [
{
"id": "video-player.autoplay",
"type": "boolean",
"default": false,
"title": "Autoplay videos"
}
]
},
"permissions": ["storage.local", "network.fetch", "ui.notifications"],
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"activationEvents": ["onSlot:content-area", "onCommand:video-player.play"],
"sandbox": {
"type": "iframe",
"allow": ["scripts", "forms"],
"csp": "script-src 'self'; style-src 'self' 'unsafe-inline'"
}
}

Key manifest fields:

FieldPurpose
idGlobally unique identifier (reverse domain notation)
host.minVersionMinimum host version for compatibility
mainEntry point for widget code
remoteEntryModule Federation entry (if applicable)
contributes.slotsUI contribution points
contributes.commandsRegistered commands
permissionsRequired host capabilities
activationEventsWhen to load the widget
sandboxIsolation requirements

Contribution points are named locations in the host UI where widgets can render content. The host declares available slots; widgets register for slots they want to fill.

host/src/SlotRegistry.ts
interface Slot {
id: string
multiple: boolean // Can multiple widgets contribute?
props: Record<string, unknown> // Props passed to widget
}
interface SlotContribution {
widgetId: string
slotId: string
component: string
priority: number
}
class SlotRegistry {
private slots = new Map<string, Slot>()
private contributions = new Map<string, SlotContribution[]>()
registerSlot(slot: Slot) {
this.slots.set(slot.id, slot)
}
registerContribution(contribution: SlotContribution) {
const existing = this.contributions.get(contribution.slotId) || []
existing.push(contribution)
existing.sort((a, b) => b.priority - a.priority)
this.contributions.set(contribution.slotId, existing)
}
getContributionsForSlot(slotId: string): SlotContribution[] {
const slot = this.slots.get(slotId)
if (!slot) return []
const contributions = this.contributions.get(slotId) || []
return slot.multiple ? contributions : contributions.slice(0, 1)
}
}
host/src/ContributionPoint.tsx
function ContributionPoint({ slotId }: { slotId: string }) {
const contributions = useSlotContributions(slotId)
return (
<div className="contribution-point" data-slot={slotId}>
{contributions.map((contribution) => (
<WidgetRenderer
key={contribution.widgetId}
widgetId={contribution.widgetId}
component={contribution.component}
/>
))}
</div>
)
}

Slot patterns from VS Code:

Slot TypeExampleBehavior
viewContainerSidebar panelsMultiple widgets, tabbed
viewTree viewsSingle widget per view
statusBarItemStatus barMultiple, ordered by priority
menuContext menusMultiple, grouped

The registry is a service that maps widget IDs to their manifests and entry points. It handles:

  • Widget discovery and search
  • Version resolution
  • Dependency validation
  • URL resolution for loading
host/src/WidgetRegistry.ts
interface RegistryEntry {
id: string
manifest: WidgetManifest
versions: {
version: string
url: string
checksum: string
publishedAt: Date
}[]
}
interface ResolvedWidget {
id: string
version: string
manifestUrl: string
entryUrl: string
checksum: string
}
class WidgetRegistry {
private baseUrl: string
private cache = new Map<string, RegistryEntry>()
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async resolve(widgetId: string, versionConstraint: string = "latest"): Promise<ResolvedWidget> {
// Fetch registry entry
const entry = await this.fetchEntry(widgetId)
// Resolve version constraint (semver)
const version = this.resolveVersion(entry.versions, versionConstraint)
if (!version) {
throw new Error(`No version matching ${versionConstraint} for ${widgetId}`)
}
// Validate host compatibility
const manifest = await this.fetchManifest(version.url)
this.validateHostCompatibility(manifest)
return {
id: widgetId,
version: version.version,
manifestUrl: `${version.url}/manifest.json`,
entryUrl: `${version.url}/${manifest.main}`,
checksum: version.checksum,
}
}
private async fetchEntry(widgetId: string): Promise<RegistryEntry> {
if (this.cache.has(widgetId)) {
return this.cache.get(widgetId)!
}
const response = await fetch(`${this.baseUrl}/widgets/${widgetId}`)
if (!response.ok) {
throw new Error(`Widget not found: ${widgetId}`)
}
const entry = await response.json()
this.cache.set(widgetId, entry)
return entry
}
private resolveVersion(
versions: RegistryEntry["versions"],
constraint: string,
): RegistryEntry["versions"][0] | undefined {
if (constraint === "latest") {
return versions[0] // Assumes sorted by version desc
}
// Semver matching
return versions.find((v) => satisfies(v.version, constraint))
}
private validateHostCompatibility(manifest: WidgetManifest) {
const hostVersion = getHostVersion()
const { minVersion, maxVersion } = manifest.host
if (minVersion && !gte(hostVersion, minVersion)) {
throw new Error(`Widget requires host >= ${minVersion}`)
}
if (maxVersion && !satisfies(hostVersion, maxVersion)) {
throw new Error(`Widget incompatible with host ${hostVersion}`)
}
}
}

Registry API design:

GET /widgets # List all widgets (paginated)
GET /widgets/{id} # Get widget metadata
GET /widgets/{id}/versions # List versions
GET /widgets/{id}@{version} # Get specific version
POST /widgets # Publish new widget (authenticated)
DELETE /widgets/{id}@{version} # Unpublish version (admin)

The host exposes a controlled API surface that widgets use to interact with the system. This creates a stable contract and security boundary.

host/src/HostSDK.ts
interface HostSDK {
// Lifecycle
readonly version: string
onActivate(callback: () => void): Disposable
onDeactivate(callback: () => void): Disposable
// UI
showNotification(options: NotificationOptions): Promise<void>
showModal(options: ModalOptions): Promise<ModalResult>
registerCommand(id: string, handler: CommandHandler): Disposable
// Data
getData<T>(key: string): Promise<T | undefined>
setData<T>(key: string, value: T): Promise<void>
subscribeToData<T>(key: string, callback: (value: T) => void): Disposable
// Storage (widget-scoped)
storage: {
get<T>(key: string): Promise<T | undefined>
set<T>(key: string, value: T): Promise<void>
delete(key: string): Promise<void>
}
// Network (proxied through host)
fetch(url: string, options?: RequestInit): Promise<Response>
// Events
onEvent(eventType: string, handler: EventHandler): Disposable
emitEvent(eventType: string, payload: unknown): void
}
interface Disposable {
dispose(): void
}
interface NotificationOptions {
type: "info" | "warning" | "error"
message: string
duration?: number
actions?: { label: string; action: () => void }[]
}
host/src/IframeSDK.ts
class IframeSDKHost {
private iframe: HTMLIFrameElement
private widgetId: string
private handlers = new Map<string, Function>()
constructor(iframe: HTMLIFrameElement, widgetId: string) {
this.iframe = iframe
this.widgetId = widgetId
window.addEventListener("message", this.handleMessage)
}
private handleMessage = async (event: MessageEvent) => {
// Validate origin matches expected widget origin
if (!this.isValidOrigin(event.origin)) return
const { type, requestId, method, args } = event.data
if (type !== "SDK_CALL") return
try {
const result = await this.executeMethod(method, args)
this.respond(requestId, { success: true, result })
} catch (error) {
this.respond(requestId, { success: false, error: String(error) })
}
}
private async executeMethod(method: string, args: unknown[]): Promise<unknown> {
// Permission check
const permission = this.getRequiredPermission(method)
if (permission && !this.hasPermission(permission)) {
throw new Error(`Permission denied: ${permission}`)
}
switch (method) {
case "showNotification":
return this.showNotification(args[0] as NotificationOptions)
case "getData":
return this.getData(args[0] as string)
case "setData":
return this.setData(args[0] as string, args[1])
case "storage.get":
return this.widgetStorage.get(this.widgetId, args[0] as string)
case "fetch":
// Proxy fetch to enforce CORS and CSP
return this.proxyFetch(args[0] as string, args[1] as RequestInit)
default:
throw new Error(`Unknown method: ${method}`)
}
}
private respond(requestId: string, response: unknown) {
this.iframe.contentWindow?.postMessage(
{ type: "SDK_RESPONSE", requestId, ...response },
"*", // iframe origin already validated on receive
)
}
private getRequiredPermission(method: string): string | undefined {
const permissionMap: Record<string, string> = {
showNotification: "ui.notifications",
fetch: "network.fetch",
"storage.get": "storage.local",
"storage.set": "storage.local",
}
return permissionMap[method]
}
}
widget-sdk/src/index.ts
class WidgetSDK implements HostSDK {
private pendingRequests = new Map<
string,
{
resolve: (value: unknown) => void
reject: (error: Error) => void
}
>()
constructor() {
window.addEventListener("message", this.handleResponse)
}
private handleResponse = (event: MessageEvent) => {
const { type, requestId, success, result, error } = event.data
if (type !== "SDK_RESPONSE") return
const pending = this.pendingRequests.get(requestId)
if (!pending) return
this.pendingRequests.delete(requestId)
if (success) {
pending.resolve(result)
} else {
pending.reject(new Error(error))
}
}
private call<T>(method: string, args: unknown[] = []): Promise<T> {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
this.pendingRequests.set(requestId, { resolve: resolve as any, reject })
window.parent.postMessage({ type: "SDK_CALL", requestId, method, args }, "*")
// Timeout after 10 seconds
setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId)
reject(new Error(`SDK call timeout: ${method}`))
}
}, 10000)
})
}
// Public API implementation
get version(): string {
return "1.0.0"
}
async showNotification(options: NotificationOptions): Promise<void> {
return this.call("showNotification", [options])
}
async getData<T>(key: string): Promise<T | undefined> {
return this.call("getData", [key])
}
storage = {
get: <T>(key: string) => this.call<T>("storage.get", [key]),
set: <T>(key: string, value: T) => this.call<void>("storage.set", [key, value]),
delete: (key: string) => this.call<void>("storage.delete", [key]),
}
// ... other methods
}
// Export singleton
export const hostSDK = new WidgetSDK()

The tenant config service maps tenant identifiers to their widget configurations. This enables per-tenant customization without code changes.

CDNRegistryTenantConfigHostUserCDNRegistryTenantConfigHostUserloop[For each widget ID]Load applicationGET /config?tenant=acme{ widgets: ["video-player-premium", "analytics-basic"] }Resolve widget URL{ url, manifest, checksum }Load widget bundlesWidget codeMount widgets in slotsRender complete
Multi-tenant widget loading flow: tenant config determines widget IDs; registry resolves to URLs; CDN serves bundles.
host/src/TenantConfig.ts
interface TenantWidgetConfig {
widgetId: string
version?: string // Optional: defaults to "latest"
slot: string
config?: Record<string, unknown> // Widget-specific config
enabled: boolean
}
interface TenantConfig {
tenantId: string
widgets: TenantWidgetConfig[]
featureFlags: Record<string, boolean>
theme?: ThemeConfig
}
class TenantConfigService {
private configCache = new Map<string, TenantConfig>()
private baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async getConfig(tenantId: string): Promise<TenantConfig> {
// Check cache first
if (this.configCache.has(tenantId)) {
return this.configCache.get(tenantId)!
}
const response = await fetch(`${this.baseUrl}/tenants/${tenantId}/config`)
if (!response.ok) {
// Fall back to default config
return this.getDefaultConfig(tenantId)
}
const config = await response.json()
this.configCache.set(tenantId, config)
return config
}
private getDefaultConfig(tenantId: string): TenantConfig {
return {
tenantId,
widgets: [], // No widgets by default
featureFlags: {},
}
}
async refreshConfig(tenantId: string): Promise<TenantConfig> {
this.configCache.delete(tenantId)
return this.getConfig(tenantId)
}
}
host/src/WidgetOrchestrator.ts
interface LoadedWidget {
id: string
version: string
slot: string
instance: WidgetInstance
state: "loading" | "active" | "error" | "inactive"
}
class WidgetOrchestrator {
private tenantConfig: TenantConfigService
private registry: WidgetRegistry
private loadedWidgets = new Map<string, LoadedWidget>()
private slotRegistry: SlotRegistry
async initialize(tenantId: string) {
// 1. Fetch tenant configuration
const config = await this.tenantConfig.getConfig(tenantId)
// 2. Filter enabled widgets
const enabledWidgets = config.widgets.filter((w) => w.enabled)
// 3. Resolve all widgets in parallel
const resolutions = await Promise.allSettled(
enabledWidgets.map((w) => this.registry.resolve(w.widgetId, w.version)),
)
// 4. Load successfully resolved widgets
for (let i = 0; i < resolutions.length; i++) {
const resolution = resolutions[i]
const widgetConfig = enabledWidgets[i]
if (resolution.status === "fulfilled") {
await this.loadWidget(resolution.value, widgetConfig)
} else {
console.error(`Failed to resolve ${widgetConfig.widgetId}:`, resolution.reason)
// Continue loading other widgets
}
}
}
private async loadWidget(resolved: ResolvedWidget, config: TenantWidgetConfig) {
const loadedWidget: LoadedWidget = {
id: resolved.id,
version: resolved.version,
slot: config.slot,
instance: null!,
state: "loading",
}
this.loadedWidgets.set(resolved.id, loadedWidget)
try {
// Fetch manifest to determine loading strategy
const manifest = await this.fetchManifest(resolved.manifestUrl)
// Load based on sandbox type
const instance = await this.createWidgetInstance(manifest, resolved, config)
loadedWidget.instance = instance
loadedWidget.state = "active"
// Register contribution points
this.registerContributions(manifest, resolved.id)
} catch (error) {
loadedWidget.state = "error"
console.error(`Failed to load widget ${resolved.id}:`, error)
}
}
private async createWidgetInstance(
manifest: WidgetManifest,
resolved: ResolvedWidget,
config: TenantWidgetConfig,
): Promise<WidgetInstance> {
const sandboxType = manifest.sandbox?.type || "none"
switch (sandboxType) {
case "iframe":
return this.createIframeWidget(resolved, manifest, config)
case "shadow-dom":
return this.createShadowDomWidget(resolved, manifest, config)
case "wasm":
return this.createWasmWidget(resolved, manifest, config)
case "none":
default:
return this.createFederatedWidget(resolved, manifest, config)
}
}
private async createIframeWidget(
resolved: ResolvedWidget,
manifest: WidgetManifest,
config: TenantWidgetConfig,
): Promise<IframeWidgetInstance> {
const iframe = document.createElement("iframe")
// Apply sandbox restrictions
const sandbox = manifest.sandbox!
iframe.sandbox.add(...sandbox.allow.map((a) => `allow-${a}`))
// Set CSP via attribute (embedded enforcement)
if (sandbox.csp) {
iframe.setAttribute("csp", sandbox.csp)
}
iframe.src = resolved.entryUrl
return new IframeWidgetInstance(iframe, resolved.id, config.config)
}
async unloadWidget(widgetId: string) {
const widget = this.loadedWidgets.get(widgetId)
if (!widget) return
widget.state = "inactive"
// Cleanup instance
await widget.instance.dispose()
// Remove contributions
this.slotRegistry.removeContributions(widgetId)
this.loadedWidgets.delete(widgetId)
}
}
host/src/FeatureFlags.ts
interface FeatureFlagService {
isEnabled(flag: string, context: FlagContext): boolean
getVariant<T>(flag: string, context: FlagContext): T | undefined
}
interface FlagContext {
tenantId: string
userId?: string
environment: "production" | "staging" | "development"
}
class WidgetFeatureFlags {
private flags: FeatureFlagService
isWidgetEnabled(widgetId: string, tenantConfig: TenantWidgetConfig, context: FlagContext): boolean {
// 1. Check tenant config first
if (!tenantConfig.enabled) return false
// 2. Check feature flag override
const flagKey = `widget.${widgetId}.enabled`
if (this.flags.isEnabled(flagKey, context) === false) {
return false
}
// 3. Check rollout percentage
const rolloutFlag = `widget.${widgetId}.rollout`
const rollout = this.flags.getVariant<number>(rolloutFlag, context)
if (rollout !== undefined) {
const userHash = this.hashUser(context.userId || context.tenantId)
return userHash < rollout
}
return true
}
private hashUser(userId: string): number {
// Deterministic hash for consistent experience
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = (hash << 5) - hash + userId.charCodeAt(i)
hash |= 0
}
return Math.abs(hash) % 100
}
}

Widget loading competes with the host application for main thread time. Poor loading strategies cause jank.

Loading waterfall (problematic):

[Host init]--[Fetch config]--[Fetch widget A]--[Parse A]--[Fetch widget B]--[Parse B]
|
[First paint]

Optimized loading:

[Host init]--[Fetch config]--[Render host skeleton]
|
├──[Fetch A]──[Parse A]
└──[Fetch B]──[Parse B]
|
[Progressive paint]
host/src/OptimizedLoader.ts
async function loadWidgetsOptimized(widgetConfigs: TenantWidgetConfig[]) {
// 1. Render skeleton immediately
renderSkeletons(widgetConfigs)
// 2. Start all fetches in parallel
const fetchPromises = widgetConfigs.map((config) => fetchWidgetBundle(config.widgetId))
// 3. Process widgets as they complete (not waiting for all)
for await (const result of raceSettled(fetchPromises)) {
if (result.status === "fulfilled") {
// Yield to main thread between widget initializations
await yieldToMain()
await initializeWidget(result.value)
}
}
}
function yieldToMain(): Promise<void> {
return new Promise((resolve) => {
if ("scheduler" in window && "yield" in (window as any).scheduler) {
;(window as any).scheduler.yield().then(resolve)
} else {
setTimeout(resolve, 0)
}
})
}
async function* raceSettled<T>(promises: Promise<T>[]): AsyncGenerator<PromiseSettledResult<T>> {
const remaining = new Set(promises.map((p, i) => i))
while (remaining.size > 0) {
const results = await Promise.race(
Array.from(remaining).map(async (i) => {
try {
const value = await promises[i]
return { index: i, status: "fulfilled" as const, value }
} catch (reason) {
return { index: i, status: "rejected" as const, reason }
}
}),
)
remaining.delete(results.index)
yield results.status === "fulfilled"
? { status: "fulfilled", value: results.value }
: { status: "rejected", reason: results.reason }
}
}
Device TypePractical JS Heap LimitWidget Budget
Low-end mobile50-100MB5-10MB per widget
Mid-range mobile200-300MB20-30MB per widget
Desktop500MB-1GB50-100MB per widget

Memory management strategies:

host/src/WidgetMemoryManager.ts
class WidgetMemoryManager {
private readonly maxTotalMemory: number
private readonly maxWidgetMemory: number
private activeWidgets = new Map<string, WidgetMemoryStats>()
constructor(config: { maxTotalMemory: number; maxWidgetMemory: number }) {
this.maxTotalMemory = config.maxTotalMemory
this.maxWidgetMemory = config.maxWidgetMemory
// Monitor memory pressure
if ("memory" in performance) {
this.startMemoryMonitoring()
}
}
canLoadWidget(estimatedMemory: number): boolean {
const currentUsage = this.getTotalUsage()
return currentUsage + estimatedMemory <= this.maxTotalMemory
}
onWidgetOverMemory(widgetId: string) {
console.warn(`Widget ${widgetId} exceeded memory limit`)
// Option 1: Unload widget
// this.unloadWidget(widgetId);
// Option 2: Notify widget to reduce memory
this.notifyWidget(widgetId, "MEMORY_PRESSURE")
}
private startMemoryMonitoring() {
setInterval(() => {
const memory = (performance as any).memory
const usedRatio = memory.usedJSHeapSize / memory.jsHeapSizeLimit
if (usedRatio > 0.9) {
// Critical memory pressure
this.handleMemoryPressure("critical")
} else if (usedRatio > 0.7) {
// Warning level
this.handleMemoryPressure("warning")
}
}, 5000)
}
private handleMemoryPressure(level: "warning" | "critical") {
// Unload inactive widgets first
for (const [widgetId, stats] of this.activeWidgets) {
if (!stats.visible && level === "critical") {
this.unloadWidget(widgetId)
}
}
}
}

Widgets need persistent storage but share quotas with the host:

Storage TypeQuotaWidget Strategy
localStorage5MBAvoid; use IndexedDB
IndexedDB50% of disk (Chrome)Namespace per widget
Cache API50% of diskCareful eviction
host/src/WidgetStorage.ts
class WidgetStorageManager {
private db: IDBDatabase | null = null
private readonly storeName = "widget_storage"
async initialize() {
this.db = await this.openDatabase()
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open("widget_host", 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "key" })
}
}
})
}
async get<T>(widgetId: string, key: string): Promise<T | undefined> {
const compositeKey = `${widgetId}:${key}`
return this.dbGet(compositeKey)
}
async set<T>(widgetId: string, key: string, value: T): Promise<void> {
const compositeKey = `${widgetId}:${key}`
// Check quota before write
const estimate = await navigator.storage.estimate()
const usageRatio = (estimate.usage || 0) / (estimate.quota || 1)
if (usageRatio > 0.9) {
// Evict old widget data
await this.evictOldData(widgetId)
}
return this.dbSet(compositeKey, value)
}
async getWidgetUsage(widgetId: string): Promise<number> {
// Estimate storage used by this widget
const keys = await this.getWidgetKeys(widgetId)
let totalSize = 0
for (const key of keys) {
const value = await this.dbGet(key)
totalSize += new Blob([JSON.stringify(value)]).size
}
return totalSize
}
}

Context: Desktop code editor with 40,000+ extensions.

Architecture:

  • Extensions run in separate Extension Host process (Node.js)
  • UI extensions use webview (Chromium iframe with restricted API)
  • Communication via IPC (Inter-Process Communication)

Key design decisions:

DecisionRationale
Separate processMisbehaving extensions can’t crash editor
Activation eventsLazy loading—extensions load only when needed
Contribution pointsDeclarative UI integration via contributes manifest
Sandboxed webviewsHTML panels isolated from VS Code DOM

Manifest example:

{
"name": "my-extension",
"activationEvents": ["onLanguage:javascript"],
"contributes": {
"commands": [{ "command": "ext.doThing", "title": "Do Thing" }],
"menus": {
"editor/context": [{ "command": "ext.doThing" }]
}
}
}

Outcome: Extensions enhance functionality without affecting editor stability. Process isolation means extension crashes are recoverable.

Context: Web-based design tool; plugins manipulate design documents.

Architecture:

  • UI layer: Standard iframe for plugin UI
  • Logic layer: QuickJS JavaScript engine compiled to WASM
  • Document access only through controlled API

Why WASM sandbox?

  1. Cannot use eval() (security)
  2. Plugins must not access Figma’s DOM
  3. Need deterministic execution for collaborative editing

Two-process model:

Plugin UI (iframe) ←→ postMessage ←→ Plugin Logic (WASM) ←→ Figma API ←→ Document

Security properties:

  • Plugin cannot access browser APIs directly
  • Plugin cannot manipulate Figma’s UI
  • All document operations go through audited API
  • Plugin code runs in capability-constrained sandbox

Trade-off accepted: QuickJS is ~10-30x slower than V8. Acceptable because document operations are the bottleneck, not JavaScript execution.

Source: How Figma Built the Plugin System

Context: E-commerce platform; apps extend merchant admin functionality.

Architecture:

  • Apps run in iframe embedded in Shopify admin
  • Communication via App Bridge SDK (postMessage abstraction)
  • OAuth for authentication; session tokens for API access

App Bridge SDK:

import { createApp } from "@shopify/app-bridge"
import { Redirect, Toast } from "@shopify/app-bridge/actions"
const app = createApp({
apiKey: "api-key",
shopOrigin: "myshop.myshopify.com",
})
// Show toast notification (rendered by Shopify, not iframe)
const toast = Toast.create(app, { message: "Order updated", duration: 5000 })
toast.dispatch(Toast.Action.SHOW)
// Navigate within Shopify admin
const redirect = Redirect.create(app)
redirect.dispatch(Redirect.Action.ADMIN_PATH, "/orders")

Multi-tenancy model:

  • Each Shopify store is a tenant
  • Apps install per-store with store-specific credentials
  • App Bridge passes shop context on every request

Security properties:

  • Apps cannot access other stores’ data
  • OAuth scopes limit API access per app
  • iframe sandbox prevents DOM manipulation

Source: Shopify App Bridge Documentation

Context: Enterprise CRM with third-party components from multiple ISVs.

Architecture:

  • Components run in Lightning Web Security (LWS) virtual environment
  • Namespace isolation: c-myComponent vs partner-theirComponent
  • Shadow DOM for style encapsulation

Lightning Web Security:

┌─────────────────────────────────────────┐
│ Salesforce Page │
├─────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Namespace A │ │ Namespace B │ │
│ │ (Virtual Env)│ │ (Virtual Env)│ │
│ │ Component1 │ │ Component2 │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘

Security properties:

  • Components in different namespaces cannot access each other’s resources
  • Distorted global objects prevent prototype pollution
  • API calls go through Salesforce proxy with permission checks

Trade-off: Slightly different JavaScript semantics (global object distortion). Most code works unchanged; edge cases require adaptation.

Source: Lightning Web Security Architecture

The mistake: Fetching and initializing all widgets on page load regardless of visibility.

Example: Dashboard with 20 widgets; user only sees 4 above the fold. Loading all 20 blocks main thread for 2+ seconds.

Why it happens: Simpler implementation; deferred loading requires tracking visibility.

Solution:

class LazyWidgetLoader {
private observer: IntersectionObserver
private pendingWidgets = new Map<Element, string>()
constructor() {
this.observer = new IntersectionObserver(
this.handleIntersection,
{ rootMargin: "100px" }, // Preload slightly before visible
)
}
registerSlot(element: Element, widgetId: string) {
this.pendingWidgets.set(element, widgetId)
this.observer.observe(element)
}
private handleIntersection: IntersectionObserverCallback = (entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const widgetId = this.pendingWidgets.get(entry.target)
if (widgetId) {
this.loadWidget(widgetId, entry.target)
this.pendingWidgets.delete(entry.target)
this.observer.unobserve(entry.target)
}
}
}
}
}

The mistake: Handling all messages without checking origin.

// DANGEROUS: No origin check
window.addEventListener("message", (event) => {
if (event.data.type === "WIDGET_REQUEST") {
handleWidgetRequest(event.data) // Attacker can send messages too
}
})

Impact: Attacker opens your site in iframe, sends malicious messages, triggers actions with user’s credentials.

Solution:

const TRUSTED_ORIGINS = new Set(["https://widget1.example.com", "https://widget2.example.com"])
window.addEventListener("message", (event) => {
// CRITICAL: Always validate origin
if (!TRUSTED_ORIGINS.has(event.origin)) {
console.warn("Rejected message from untrusted origin:", event.origin)
return
}
handleWidgetRequest(event.data)
})

The mistake: Host uses React 18, widget bunled with React 17, both load.

Impact: Two React instances cause context mismatches, hooks fail mysteriously.

Solution:

// webpack.config.js (host)
new ModuleFederationPlugin({
shared: {
react: {
singleton: true,
requiredVersion: "^18.0.0",
strictVersion: true, // Fail if incompatible
},
},
})

Also validate in registry:

function validateWidgetCompatibility(manifest: WidgetManifest) {
const hostReact = "18.2.0"
const widgetReact = manifest.dependencies?.react
if (widgetReact && !satisfies(hostReact, widgetReact)) {
throw new Error(`Widget requires React ${widgetReact}, host has ${hostReact}`)
}
}

The mistake: Loading widgets but never unloading them when no longer needed.

Impact: Memory grows unbounded; 50 widgets loaded over time, only 5 visible.

Solution:

interface WidgetInstance {
activate(): Promise<void>
deactivate(): Promise<void>
dispose(): Promise<void>
}
class WidgetLifecycleManager {
private activeWidgets = new Map<string, WidgetInstance>()
private visibleWidgets = new Set<string>()
async handleVisibilityChange(widgetId: string, visible: boolean) {
const widget = this.activeWidgets.get(widgetId)
if (!widget) return
if (visible) {
this.visibleWidgets.add(widgetId)
await widget.activate()
} else {
this.visibleWidgets.delete(widgetId)
await widget.deactivate()
// Unload after period of invisibility
setTimeout(() => {
if (!this.visibleWidgets.has(widgetId)) {
this.unloadWidget(widgetId)
}
}, 60000) // 1 minute
}
}
private async unloadWidget(widgetId: string) {
const widget = this.activeWidgets.get(widgetId)
if (widget) {
await widget.dispose()
this.activeWidgets.delete(widgetId)
}
}
}

The mistake: Synchronously parsing and executing large widget bundles.

Impact: UI freezes during widget load; user sees blank screen.

Solution:

async function initializeWidgetAsync(code: string, container: Element) {
// Parse in chunks using requestIdleCallback
const chunks = splitIntoChunks(code, 50000) // 50KB chunks
for (const chunk of chunks) {
await new Promise<void>((resolve) => {
requestIdleCallback(
() => {
// Parse chunk
parseChunk(chunk)
resolve()
},
{ timeout: 100 },
)
})
}
// Initialize widget after parsing
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
mountWidget(container)
resolve()
})
})
}

Building a multi-tenant pluggable widget framework requires navigating three axes:

  1. Security vs Performance: iframe/WASM isolation is safest but has overhead; Module Federation is fast but trusts widgets
  2. Flexibility vs Complexity: More extension points enable richer widgets but increase API surface to maintain
  3. Per-tenant customization vs Operational simplicity: Feature flags add deployment complexity but enable fine-grained control

Recommended starting points by use case:

ScenarioIsolationLoadingRegistry
Internal micro-frontendsModule FederationModule Federationnpm/internal registry
Third-party app marketplaceiframe + postMessageDynamic scriptCustom registry + OAuth
High-security (financial)WASM sandboxDynamic fetchSigned manifests
Design tool pluginsWASM (logic) + iframe (UI)Controlled loaderCurated store

Key design decisions to make early:

  1. What’s your trust model? Internal-only vs open marketplace changes everything
  2. What API surface will you support? Once published, it’s hard to change
  3. How will you version the host SDK? Widgets depend on stability
  4. How do you handle widget failures? Graceful degradation vs hard failure

The frameworks that succeed (VS Code, Figma, Shopify) share a common pattern: minimal core, maximal extensibility through well-defined contribution points, and strong isolation boundaries. Start with clear boundaries; relaxing them later is easier than adding them.

  • Understanding of JavaScript module systems (ES modules, CommonJS)
  • Familiarity with webpack or Vite bundling
  • Knowledge of browser security model (same-origin policy, CSP)
  • Basic understanding of iframe and postMessage APIs
TermDefinition
Module FederationWebpack 5 feature allowing multiple builds to share code at runtime
Contribution PointNamed extension point in host UI where widgets can render
ManifestJSON file declaring widget metadata, capabilities, and requirements
Activation EventTrigger that causes a widget to load (e.g., slot visible, command invoked)
SandboxRestricted execution environment limiting widget capabilities
postMessageBrowser API for cross-origin communication between windows/iframes
Shadow DOMBrowser API for encapsulated DOM subtree with style isolation
WASMWebAssembly; binary instruction format for sandboxed execution
QuickJSLightweight JavaScript engine; can be compiled to WASM for sandboxing
SingletonShared dependency constraint ensuring only one instance loads
  • Widget frameworks follow the microkernel pattern: minimal core + pluggable extensions
  • Isolation strategies trade security for performance (iframe > WASM > Shadow DOM > Module Federation)
  • Manifests declare capabilities declaratively; host renders contributions without loading all code
  • Multi-tenancy uses config service + feature flags to determine per-tenant widget sets
  • Registry resolves widget IDs to versioned URLs with compatibility validation
  • Host SDK provides controlled API surface; widgets cannot access arbitrary host internals
  • Memory and main thread budgets constrain how many widgets can load simultaneously

Module Federation:

Browser Sandboxing:

Shadow DOM:

Production Implementations:

Multi-Tenancy:

Communication Patterns:

Read more

  • Previous

    Real-Time Sync Client

    System Design / Frontend System Design 26 min read

    Client-side architecture for real-time data synchronization: transport protocols, connection management, conflict resolution, and state reconciliation patterns used by Figma, Notion, Discord, and Linear.

  • Next

    Bundle Splitting Strategies

    System Design / Frontend System Design 19 min read

    Modern JavaScript applications ship megabytes of code by default. Without bundle splitting, users download, parse, and execute the entire application before seeing anything interactive—regardless of which features they’ll actually use. Bundle splitting transforms monolithic builds into targeted delivery: load the code for the current route immediately, defer everything else until needed. The payoff is substantial—30-60% reduction in initial bundle size translates directly to faster Time to Interactive (TTI) and improved Core Web Vitals.