How to build a JavaScript SDK: 5 real teardowns
If you ship a video player, camera feed or chat widget that customers paste into their site with one script tag, the first decision is the embedding shape. Render inside your own iframe if you must stay clear of the customer's CSS and CSP or the data is sensitive, and keep the version to yourself. Inject into their DOM if customers build their own UI, and let them pin an exact version. The bundles of YouTube, Safie, Vimeo, Mux Player and Video.js, fetched on 2026-08-08, laid out against six decisions: shape, loader/body split, CSP, postMessage, versioning and initialization. Sizes and headers are measured; behavior comes from reading the shipped JavaScript; the skeleton code in the final part has not been run.
What you can do after reading
- Pick the embedding shape first: an iframe that keeps the version to yourself if the customer's CSS and CSP must not interfere or the data is sensitive, and DOM injection with a URL that pins an exact version if customers build their own UI
- Carry seven patterns from YouTube's 993-byte loader into your own, including the double-load guard and
currentScript.noncepropagation - Lock down the inside of your iframe the way Safie does, with a per-request nonce CSP and a narrow
connect-src - Make postMessage a contract that stays unambiguous even with two embeds on one page, by matching the origin exactly and checking
event.source - Read the skeleton of a minimal embeddable SDK built by making the six decisions one at a time, and adapt it to your own requirements
Series chapters7 chapters
- 0 How to build a JavaScript SDK: 5 real teardowns Pillar (you are here)
- 1 YouTube iframe API: what 993 bytes actually ship YouTube's iframe API ships a 993-byte loader that fetches a 26.8 KB body. Reading it line by line surfaces seven patterns every embeddable SDK reuses.
- 2 Iframe isolation and CSP nonces: Safie's 2.61 MB body Safie's 160 KB loader pushes 2.61 MB into an iframe. Inside, a CSP with a per-request nonce; between parent and child, SYN/ACK and AES-GCM.
- 3 postMessage contracts: Vimeo, YouTube, Safie compared Three of five SDKs talk parent to child over postMessage. Line up Vimeo, YouTube and Safie, and sensitivity and complexity set the protocol generation.
- 4 cache-control and version pinning: 5 SDKs split two ways Measuring cache-control on 5 SDKs: the iframe ones never let customers pick a version, the DOM ones leave the choice to the customer. Why the split is so clean.
- 5 Web Components vs data attributes: 3 SDK init styles An SDK's entry point comes in three styles: YouTube's imperative API, Video.js's data-setup, Mux's Web Components. How each guards against double loads, too.
- 6 Build your own embed SDK: a chat widget in 6 decisions The six decisions from the teardowns, made again by writing a minimal embeddable SDK: a chat widget that runs from one script tag, written end to end.
Some products have to run inside someone else’s website: video players, camera feeds, chat widgets. Ship them as an npm package and only customers with engineers and a build step can install them. Ship a CDN URL plus one <script> tag and they run anywhere HTML can be pasted, CMS installs included. Where near-zero setup is the condition for being adopted at all, this is the only way to ship.
Behind that one line sit six decisions: the embedding shape, the loader/body split, CSP, the postMessage contract, versioning and caching, and the initialization entry point. And the code you ship runs inside the customer’s page with the same privileges as their own scripts.
If your code must stay clear of the customer’s CSS and CSP, or the data it handles would hurt to leak, render inside your own iframe and keep the version to yourself. If customers build their own UI or want your SDK to blend into their framework, inject into their DOM and let them choose a URL that pins an exact version. Once the embedding shape is chosen, the versioning policy and the need for postMessage mostly follow. Across the five products here, the iframe SDKs (YouTube, Safie, Vimeo) all refuse to let customers pin a version, and both DOM SDKs let customers choose a version without using postMessage: Video.js requires a version in the URL, and Mux Player caches a pinned URL immutable for a year.

The other decisions don’t split as cleanly. Whether to split the loader from the body depends on whether you want to swap the body on your own schedule. The initialization entry point should match whether your customers write static HTML or a single-page app. How heavy to make the CSP and the postMessage protocol depends, as I read it, on how sensitive the data is and how much state parent and child share. Double-load guards are the exception: four of the five ship one, so treat it as a default rather than a choice.
This series fetches the five bundles and reads them, one decision per part. Teardown first, abstraction second. The last part makes the six decisions again and builds a minimal embeddable SDK.
The common task
A comparison only means something when all five products do the same job.
- The job: the customer pastes one
<script>tag, and a UI that plays video appears on their page (a camera feed for Safie, video for the other four) - Starting point: the URL the customer pastes (the loader), followed through to the body it pulls in
- Fetching: plain
curlon 2026-08-08, no auth. Sizes are the HTTP body; gzip is what actually crosses the wire - Source of truth: the shipped JavaScript and the response headers, not the public docs
- Re-checks: Safie’s key exchange, Vimeo’s wait-for-ready step and the double-load guards were re-fetched and re-read on 2026-09-30

Keeping the subject to video means a difference shows up as a design difference rather than a difference in what’s being embedded. The one thing that isn’t held constant is how sensitive the data is. Safie carries access to recorded camera footage; YouTube serves public videos that anyone can watch. As I read it, that gap is what shows up in the CSP and postMessage columns below.
This is not a ranking of which SDK is best. Each is tuned to different constraints, and the interesting part is how constraints map to decisions. I read bundles rather than docs because docs describe the API surface, while the bundle shows what one HTTP request actually delivers, and the two sometimes disagree.

The five products
| Product | What it embeds | URL the customer pastes | In one line |
|---|---|---|---|
| Safie | camera feed | safie.link/sdk/js/api/v1/latest/ | a 160 KB loader that isolates a 2.61 MB body in an iframe |
| YouTube | video | youtube.com/iframe_api | a 993-byte loader that injects a body with a build ID in its URL |
| Vimeo | video | player.vimeo.com/api/player.js | iframe-based but one file; attaches to an iframe the customer writes, or builds one inside an element |
| Mux Player | video | cdn.jsdelivr.net/npm/@mux/mux-player | a batteries-included Web Component in the customer’s DOM |
| Video.js | video | vjs.zencdn.net/8.23.4/video.min.js | a library that turns the customer’s <video> element into a player |
The first three are iframe-based; the last two inject into the DOM.
Five products, six decisions

| Product | Shape | Loader and body | CSP | postMessage generation | Version | Initialization |
|---|---|---|---|---|---|---|
| Safie | iframe (4 frames, role set by hash) | split, 160 KB → 2.61 MB | per-request nonce inside the iframe, connect-src limited to Safie’s own domains | 3, encrypted (syn → ack → connected, parent → child in AES-GCM) | not pinnable (/latest/ with no-cache) | imperative (namespace); no double-load guard found |
| YouTube | iframe | split, 993 B → 26.8 KB | the loader adapts to the host page’s CSP via Trusted Types and nonce propagation | 2, state sync (listening → initialDelivery, exact origin match) | not pinnable (loader max-age=0, body URL carries a build ID) | imperative (callback); window.YT guard |
| Vimeo | iframe (attached to the customer’s iframe, or built by the SDK inside an element) | single file, 24.4 KB | not checked | 1, minimal (ping → ready, first origin pinned and event.source checked) | not pinnable (unversioned URL, max-age=1800) | imperative (constructor); marks initialized elements |
| Mux Player | DOM (Web Components) | single file, 1.06 MB | runs under the customer’s CSP (by construction; not checked separately) | none | pinnable (@3.7.0 is cached immutable for a year) | Web Components; customElements.get guard |
| Video.js | DOM (<video> element) | single file, 690 KB | runs under the customer’s CSP (by construction; not checked separately) | none | exact version required (/8/ returns 403) | data- attributes; skips elements already set up |
The Version and postMessage columns split along exactly the same line as the Shape column. None of the three iframe SDKs lets customers pin a version, and neither DOM SDK uses postMessage, while both let customers choose a version.
The bundles don’t say why the versioning policy follows the shape so closely. What follows is my reading. An iframe SDK has a parent (the customer’s page) and a child (your own-origin frame) that talk over postMessage, so the protocol version has to stay in step on both sides, and a customer who leaves an old loader pasted forever would break that. A DOM SDK has no parent-child conversation; instead, API changes break the customer’s own code, so both products settled on letting customers decide when to upgrade.
The further split in CSP and postMessage within the iframe group comes, as I read it, from how sensitive the data is. YouTube and Vimeo, serving public video, don’t encrypt anything. Safie, which handles access to recorded footage, sets a per-request nonce CSP inside its frame and encrypts parent-to-child commands with AES-GCM. Video.js is a library, so it leaves security to the customer’s side, and Mux enters the customer’s DOM as a batteries-included Web Component and gives up isolation in exchange.
What each column measures
The six columns in the table are the yardsticks.
| Decision | What it looks at | Covered in |
|---|---|---|
| Shape | Render inside your own-origin iframe, or inject into the customer’s DOM. Isolation costs you: every call has to go through postMessage | Parts 2 and 4 |
| Loader and body | Whether the URL the customer pastes (the loader) is separate from the code that does the work (the body). Splitting lets you swap the body without changing the customer’s URL | Part 1 |
| CSP | Whether you live under the host page’s CSP or set your own inside a frame. Setting your own lets you limit where a compromised script could send data | Parts 1 and 2 |
| postMessage generation | How heavy the parent-child protocol is: 1 minimal (wait for ready), 2 state sync (push initial state), 3 encrypted | Part 3 |
| Version | Whether customers choose a version or you switch it for them. The loader’s and body’s cache-control headers express the policy directly | Part 4 |
| Initialization | The entry point customers call (imperative, data- attributes or Web Components), and what happens when the SDK is loaded twice | Part 5 |
Two of the six set the shape

Of the six, the embedding shape and the loader split decide what the SDK looks like from the outside. Going iframe makes splitting tempting (the customer’s page needs a postMessage shim, the frame needs the whole UI), though Vimeo is the exception in this sample: iframe-based, yet player.js is a single file. The DOM-plus-split quadrant is empty. My reading is that for a DOM SDK, a split does little to keep the customer’s URL fixed while swapping the body, and leaves only the cost of an extra request.
What the shape changes becomes clear when you list what happens on the customer’s page.
| On the customer’s page | Injected into the DOM | Isolated in an iframe |
|---|---|---|
| CSS | their stylesheet overrides yours | scoped to the frame |
| JS | their code can rewrite yours | separated by the same-origin policy |
| Cookies | you share their jar | bound to your own origin |
| CSP | you live under theirs | you set your own inside the frame |
| What it costs | nothing | one extra request, and postMessage RPC for every call |
The moment the customer writes <script src="…">, your script has XSS-equivalent power on their page. An iframe keeps whatever runs inside it away from the parent’s DOM, cookies and forms. It does not stop requests leaving the frame, and if you skip event.origin checks on postMessage, an attacker’s frame can send you commands. Choose isolation but get careless at the protocol layer, and the same hole opens up again. That is the core of Parts 2 and 3.

Which one fits your situation
This is the same table as at the end of the final part, Build your own embed SDK: a chat widget in 6 decisions.
| Situation | What to choose | Why it works |
|---|---|---|
| The customer site’s CSS, JS and cookies must not interfere with yours | An iframe. Split the loader from the body, and don’t let customers choose a version | The customer’s styles and window.fetch rewrites can’t reach you. A loader refetched every time keeps parent and child on the same version |
| Customers build their own UI or want it to blend into their framework | DOM injection (Web Components). Let customers choose a URL that can name an exact version | Once customers write a version, they choose when to upgrade even if the API changes (Video.js requires a version; Mux Player, if you write one, is cached immutable for a year) |
| You handle things that would hurt to leak, such as auth tokens or recorded footage | Set a per-request nonce CSP inside the iframe and lock connect-src to your own hosts | Even a hijacked frame can’t carry data out (Safie). Static hosting alone can’t build it |
| Parent and child share state | Wait for the child’s ready signal, then push the initial state | YouTube’s listening → initialDelivery |
| You may be embedded two or more times on the same page | Check event.source in addition to an exact origin match | Both iframes have the same origin, so origin alone can’t tell whether it’s your iframe |
| Customers use static HTML or a CMS and write no JS | Initialize from data attributes | Like Video.js’s data-setup, it works just by pasting |
| Customers are split between static HTML and single-page apps | Initialize imperatively, with a deferred command queue | Calls made before load are queued in an array and replayed after load, so the same code works in both |
| Any shape | Add a double-load guard from the first commit | Tag managers fire snippets twice. Four of the five products ship one |
Once the top two rows settle the shape, the versioning policy is settled with it. The remaining rows are about what to add inside the shape you chose, and the deciding factors are how sensitive the data is and how your customers write code.

Verification status
Sizes and headers are measured; behavior comes from reading the shipped JavaScript. The code in the final part has not been run.
| Part | Measured (sizes and headers fetched with curl) | Read from source (behavior found by grepping and reading the shipped JS) | Not run |
|---|---|---|---|
| This page | loader and body sizes for all five (including Vimeo 24.4 KB, Mux Player 1.06 MB and Video.js 690 KB, measured with curl on 2026-08-08) | the matrix cells for CSP, postMessage and initialization (details in Parts 2 to 5) | ― |
| 1 Loader and body | YouTube’s loader at 993 B / 572 B gzip, the 26.8 KB body, their cache-control headers | the seven loader patterns (Trusted Types, double-load guard, currentScript.nonce propagation and others) | ― |
| 2 Iframe isolation | Safie’s 160 KB loader, the breakdown of the 2.61 MB body, the CSP header on iframe.html | how the four iframes split roles, how the SYN/ACK and AES-GCM keys are handed over (re-fetched and confirmed on 2026-09-30) | ― |
| 3 postMessage | ― | the three products’ message formats, origin checks and handshakes (Vimeo’s wait-for-ready step re-fetched and confirmed on 2026-09-30) | ― |
| 4 Versions and cache-control | cache-control for all five, Video.js /8/ returning 403 | ― | ― |
| 5 Initialization | ― | the three initialization styles, double-load guards in four of the five products (re-fetched and confirmed on 2026-09-30) | ― |
| 6 Build your own | ― | ― | the skeleton loader and iframe body; not run in a browser |
Two inferences sit outside the table: why iframe SDKs refuse version pinning (to keep the parent-child protocol in step), and why the weight of CSP and postMessage splits by sensitivity (Safie handles access to recorded footage). Neither is written in the bundles, so the text flags them, along with the reason for the empty quadrant, as “my reading”. Vimeo’s CSP, and how Mux Player and Video.js behave under a customer’s CSP, were not examined in this series.

Reading order
Six parts follow this page. Read in order for the argument; jump to a part when you have one specific question.
- Part 1 YouTube iframe API: what 993 bytes actually ship: YouTube’s loader is small enough to read end to end, so this part pulls out seven patterns you can move into your own loader, including the double-load guard and
currentScript.noncepropagation. - Part 2 Iframe isolation and CSP nonces: Safie’s 2.61 MB body: how Safie moves a 2.61 MB body into an iframe from a 160 KB loader, sets a per-request nonce CSP inside the iframe, and runs a SYN/ACK and AES-GCM key exchange between parent and child, taken apart step by step.
- Part 3 postMessage contracts: Vimeo, YouTube, Safie compared: the postMessage protocols of Vimeo, YouTube and Safie as three generations, covering origin checks and handshakes, how to write message types (JSON Schema versus
.d.ts), and when encryption is needed. - Part 4 cache-control and version pinning: 5 SDKs split two ways: measuring the headers of all five shows the iframe SDKs keep the version to themselves while the DOM SDKs let customers choose, and Video.js returns 403 unless you name an exact version.
- Part 5 Web Components vs data attributes: 3 SDK init styles: the three entry points, imperative,
data-attributes and Web Components, compared. Double-load guards turned up in four products: YouTube, Vimeo, Mux Player and Video.js. - Part 6 Build your own embed SDK: a chat widget in 6 decisions: using a chat widget as the example, the six decisions from the teardowns are made again one at a time to write the skeleton of a minimal embeddable SDK.

Measurements as of 2026-08-08. Build IDs and exact byte counts will drift; the shape of the design space will not.
Was this article helpful?