← Back to series

YouTube iframe API: what 993 bytes actually ship

Part 1 of 6 How to build a JavaScript SDK: 5 real teardowns

Paste one script tag on your page and you enable YouTube’s whole iframe embed API:

<script src="https://www.youtube.com/iframe_api"></script>

The response body is 993 bytes uncompressed, 572 bytes over the wire with gzip. Every embeddable JS SDK (Vimeo, Safie, Mux, Video.js, whatever you build) solves the same set of problems in that first request. YouTube’s loader happens to solve them tersely enough to read end to end. Walking those 993 bytes alongside the browser-observable HTTP context turns up seven patterns that generalise to any script-tag SDK.

Measurements are from a fetch on 2026-08-08 (build ID 854a788e). The build ID rotates; the shape does not.

What actually comes down the wire

Two requests, not one, and the split is deliberate.

RequestURLBody sizeCache-ControlWhat it does
Loaderyoutube.com/iframe_api993 B raw / 572 B gzipprivate, max-age=0Injects the body script and sets up the global
Bodyyoutube.com/s/player/854a788e/www-widgetapi.vflset/www-widgetapi.js26.8 KBpublic, max-age=31536000 (1 year)The actual widget code

The loader URL is stable: customers put it in their HTML once and never change it. The body URL contains the build ID (854a788e), so whenever YouTube ships an update the loader points at a new URL and the old one keeps working for anyone whose page cache hasn’t refreshed. It’s a redirect-through-content: the loader is the redirect, so an in-flight browser doesn’t need a 302 round trip.

Figure: one URL the customer pins, another URL YouTube swaps. The customer's HTML has a script tag with src …/iframe_api, pasted once and never changed. The loader is youtube.com/iframe_api, 993 B (572 B gzip), with Cache-Control private, max-age=0; it is checked on every visit, so it always knows the latest build ID. The body it points to is …/s/player/854a788e/www-widgetapi.vflset/…, 26.8 KB, with Cache-Control public, max-age=31536000; its content never changes per URL, so it can be cached for a year. A first visit fetches the loader and the 26.8 KB body; later visits, for a year, fetch only the 993 B loader and take the body from cache. On an update, only the loader's content changes to point at the new build ID's URL, and the old URL keeps working. Measured 2026-08-08 (build ID 854a788e)

The loader in full

Formatted (the shipped version is one long line):

var scriptUrl = 'https://www.youtube.com/s/player/854a788e/www-widgetapi.vflset/www-widgetapi.js';
try {
  var ttPolicy = window.trustedTypes.createPolicy('youtube-widget-api', {
    createScriptURL: function (x) { return x }
  });
  scriptUrl = ttPolicy.createScriptURL(scriptUrl);
} catch (e) {}

var YT;
if (!window['YT']) YT = { loading: 0, loaded: 0 };
var YTConfig;
if (!window['YTConfig']) YTConfig = { host: 'https://www.youtube.com' };

if (!YT.loading) {
  YT.loading = 1;
  (function () {
    var l = [];
    YT.ready = function (f) { if (YT.loaded) f(); else l.push(f) };
    window.onYTReady = function () {
      YT.loaded = 1;
      var i = 0;
      for (; i < l.length; i++) try { l[i]() } catch (e) {}
    };
    YT.setConfig = function (c) {
      var k;
      for (k in c) if (c.hasOwnProperty(k)) YTConfig[k] = c[k];
    };
    var a = document.createElement('script');
    a.type = 'text/javascript';
    a.id = 'www-widgetapi-script';
    a.src = scriptUrl;
    a.async = true;
    var c = document.currentScript;
    if (c) {
      var n = c.nonce || c.getAttribute('nonce');
      if (n) a.setAttribute('nonce', n);
    }
    var b = document.getElementsByTagName('script')[0];
    b.parentNode.insertBefore(a, b);
  })();
}

That’s it. Seven patterns, in order.

Figure: the formatted loader excerpt matched to the seven patterns. Title: inside the 993 bytes, the patterns appear top to bottom. Only Pattern 2 lives in the response headers, not in the code: the loader is private, max-age=0 and the body is public, max-age=31536000. From the top of the code: Pattern 1 is var scriptUrl = '…/player/854a788e/www-widgetapi.vflset/…'; the two-URL split, holding the body URL up front. Pattern 3 is try { … window.trustedTypes.createPolicy(…); scriptUrl = ttPolicy.createScriptURL(scriptUrl); } catch (e) {}, Trusted Types, moving on even if wrapping fails. Pattern 4 is the line that sets YT = { loading: 0, loaded: 0 } when window has no YT, plus if (!YT.loading) { YT.loading = 1; …, the double-load guard checked in two stages. Pattern 5 is YT.ready = function (f) { if (YT.loaded) f(); else l.push(f) }; and window.onYTReady = function () { YT.loaded = 1; … };, the ready queue that holds calls arriving before the body. The unnumbered lines var a = document.createElement('script'); a.src = scriptUrl; a.async = true; build the body script. Pattern 6 is var n = c.nonce || c.getAttribute('nonce'); if (n) a.setAttribute('nonce', n);, nonce propagation, copying its own nonce over. Pattern 7 is b.parentNode.insertBefore(a, b);, the insertion point before the first script. Source: youtube.com/iframe_api, fetched 2026-08-08, same code apart from the build ID on 10-01

Pattern 1: the two-URL split

scriptUrl is the very first thing declared. The loader exists to point at it. There’s no attempt to inline widget code into the loader itself. The loader stays tiny (993 B) so it fits in a single TCP round trip and can be cached cheaply.

You could imagine an alternative where a single URL streams the full widget. YouTube doesn’t do it, and neither does Safie, which fronts its SDK with an iframe (Vimeo’s player.js is a single file). The reason is boring: one URL customers pin, one URL you rotate. The loader is the stable contract with the customer’s HTML; the body is the versioned artifact you replace on your schedule.

Pattern 2: the cache-control asymmetry

The loader’s Cache-Control: private, max-age=0 looks wasteful, since every visit revalidates. It isn’t. Max-age=0 with no ETag means the browser always fetches the loader, so it always learns the current build ID. The body URL contains that build ID, so once fetched it can sit in the browser cache for a year (serving hash URLs with max-age=31536000 is common practice; YouTube’s body is served with public, max-age=31536000, without immutable).

The math works out well for repeat visitors: 993 bytes of loader on every visit (or ~572 B compressed), 26.8 KB of body once and then never again for a year. First-time visitors pay both; returning visitors pay one request. Compare with a monolithic 27 KB script served with a shorter TTL: every visitor pays the whole thing on every cache miss.

The catch: if your loader isn’t tiny, the always-revalidate cost eats you. YouTube’s 993 B is fine. If your loader were 30 KB gzipped, I would want a different strategy.

Pattern 3: Trusted Types with a try/catch fallback

try {
  var ttPolicy = window.trustedTypes.createPolicy('youtube-widget-api', {
    createScriptURL: function (x) { return x }
  });
  scriptUrl = ttPolicy.createScriptURL(scriptUrl);
} catch (e) {}

Trusted Types (require-trusted-types-for 'script') is a CSP directive that forbids assigning plain strings to sinks like script.src. Under that policy, a.src = 'https://...' throws. You have to first pass the string through a named policy that returns a TrustedScriptURL.

YouTube.com’s own response headers include content-security-policy: require-trusted-types-for 'script'. So when the loader runs in that context (which it will if a YouTube-owned page is what loaded it, or if a customer’s page opts into Trusted Types), the raw string has to be laundered through a policy first. The identity function (return x) does no sanitization. The security model here is “did this URL come out of a named policy, yes or no”, not “is this URL safe”. YouTube trusts its own strings; the ceremony is what CSP requires.

The try/catch matters. Some browsers don’t have window.trustedTypes (Firefox has supported it since 148, Safari since 26), and createPolicy throws under a variety of conditions (name collision, disallowed name). The fallback is: if any of this fails, keep the raw string and hope the host page isn’t enforcing Trusted Types. On a customer page that isn’t under that policy, the raw string works fine.

Table figure: where the loader's try/catch lands in four browser and page conditions. Title: if wrapping fails it moves on; only row 4 stops. The loader tries to wrap scriptUrl in a named policy, and on failure assigns the raw string to a.src. Row 1, a browser without Trusted Types (window.trustedTypes is undefined): createPolicy throws a TypeError that is caught, scriptUrl stays a raw string, and the assignment works because nothing enforces it. Row 2, the page does not enforce (the browser supports Trusted Types): createPolicy succeeds, scriptUrl is a TrustedScriptURL, and the assignment works. Row 3, the page enforces require-trusted-types-for 'script': createPolicy succeeds, TrustedScriptURL, it works (a raw string would throw). Row 4, the page enforces and restricts names (the name is missing from the trusted-types allowlist, or the same name is used a second time): createPolicy throws a TypeError that is caught, the raw string is assigned and throws a TypeError, and the body never loads. Takeaway: wrap it, and the SDK keeps working unchanged when embedded on a row 3 page. Basis: W3C Trusted Types (createPolicy and script.src enforcement); row 4 assumes no default policy. Trusted Types support: Chrome 83, Firefox 148, Safari 26 (MDN browser-compat-data 8.1.3)

Steal the pattern: even if you don’t ship on a Trusted Types page today, wrapping the URL is cheap. When a customer inherits a strict CSP, your SDK keeps working without changes.

Pattern 4: global namespace guards

var YT;
if (!window['YT']) YT = { loading: 0, loaded: 0 };

Double-loading happens. Tag managers duplicate script tags, careless integrations paste the same snippet twice, someone else on the page has already loaded the widget. The guard says: if YT already exists, don’t touch it. The if (!YT.loading) further down is the same defense one layer deeper: if a previous copy of the loader already started, don’t inject a second body script.

Four of the five SDKs I looked at have some version of this. YouTube’s guard is stock idiom. Vimeo uses data-vimeo-initialized on the element. Mux uses customElements.get() to detect that its Web Components already exist. Video.js sets a player property on each element it has set up and skips elements that already have one. I found no window.Safie existence check in Safie’s loader (re-fetched 2026-09-30). You need this from day one; the day you don’t have it is the day a customer’s tag manager fires your snippet twice and their console fills with duplicate-registration errors.

Pattern 5: YT.ready() queues callers who arrive early

var l = [];
YT.ready = function (f) { if (YT.loaded) f(); else l.push(f) };
window.onYTReady = function () {
  YT.loaded = 1;
  var i = 0;
  for (; i < l.length; i++) try { l[i]() } catch (e) {}
};

The body script hasn’t downloaded yet when the loader finishes. But customers write:

<script src="https://www.youtube.com/iframe_api"></script>
<script>
  YT.ready(function () { /* use YT.Player */ });
</script>

…expecting YT.ready to just work. It does, because the loader defines YT.ready immediately, and it either fires the callback (if the body has finished loading) or pushes it into l. The body script, when it lands, calls window.onYTReady(), which sets loaded = 1 and flushes the queue.

Figure: a YT.ready(f) that arrives before the body goes into the loader's queue, shown across three lanes (the customer's code, the loader at 993 B, the body at 26.8 KB) and four moments. 1 The loader runs: it defines YT.ready and injects the body with async, while the body is still downloading. 2 The customer calls YT.ready(f): YT.loaded is 0, so the loader queues the call (l.push(f)). The body is not here yet. 3 The body arrives and calls window.onYTReady(); the loader sets loaded = 1 and runs the queue in order, each callback in its own try/catch so one broken callback doesn't stop the rest. 4 A later YT.ready(g) finds it already loaded and calls g() at once. At the bottom: if YT.ready were defined inside the body, the customer would call an undefined function at step 2

Two things worth stealing: the try/catch inside the callback loop (one broken callback doesn’t stop the rest), and the fact that YT.ready() is defined synchronously in the loader. If it were defined by the body script, any customer that called it before the body arrived would hit an undefined function.

Pattern 6: currentScript.nonce propagation

var c = document.currentScript;
if (c) {
  var n = c.nonce || c.getAttribute('nonce');
  if (n) a.setAttribute('nonce', n);
}

Under a CSP that allows scripts by nonce, such as script-src 'nonce-XYZ' (YouTube’s own pages set one, and so do many security-conscious customer pages), only scripts marked with the current request’s nonce are allowed to execute. Under a nonce-only policy, scripts injected without a nonce get blocked. Under a policy that adds 'strict-dynamic' (YouTube’s own pages use this kind), a script that a trusted script injects with createElement inherits that trust, so it runs even without a nonce. Copy the nonce and the loader works under either policy.

Figure: copy the nonce and the body runs under either CSP. The top row is the flow: the customer page loads the …/iframe_api loader with a script tag carrying nonce="XYZ", the loader reads currentScript.nonce, and it inserts the body's script tag, built with createElement, with nonce="XYZ". The table below gives the result for each customer-page CSP. Nonce only, script-src 'nonce-XYZ': an injected script without a nonce is blocked because the nonce doesn't match; with the nonce copied it runs because the nonce matches. With strict-dynamic, script-src 'nonce-XYZ' 'strict-dynamic': without a nonce it still runs, because a script created with createElement by a trusted script inherits its trust; with the nonce copied it also runs. The takeaway: without the copy, the body stops on pages that allow scripts by nonce only. Basis: W3C CSP Level 3 §8.2; YouTube's own pages use strict-dynamic

The loader reads its own nonce off document.currentScript and copies it to the body script it injects. Two subtleties:

  • c.nonce is the DOM property; c.getAttribute('nonce') is the fallback. The property comes first because browsers hide the attribute’s value from getAttribute for security reasons (Chrome 147 returned an empty string while the property still held the value). getAttribute is there for older browsers that lack the nonce property (Firefox added it in 75).
  • document.currentScript returns the element of the classic script that is running synchronously right now. If a bundler concatenates the loader with other code into one classic script, you get that bundle’s <script> element, and since the nonce is shared across the page, the copy still works. It is null inside a module script (type="module", or anything loaded via import) and inside callbacks that run later, such as setTimeout or event handlers. Run the loader through a bundler that outputs ESM, or wrap it in something that defers execution, and c comes back null, so the body is injected without a nonce.

If your SDK is likely to be embedded on strict-CSP pages, this is the pattern. Copy the nonce; do it defensively; document to customers that they need to load the loader as a classic script tag (not type="module", not inside a deferred wrapper).

Pattern 7: the classic insertion point

var b = document.getElementsByTagName('script')[0];
b.parentNode.insertBefore(a, b);

The old Google Analytics snippet made this idiom common, and YouTube’s loader uses it too. It works everywhere, because every page that runs any script has at least one <script> in the DOM by the time your loader executes (yours). Inserting before it puts you in the right document, in <head> when possible, without needing to know whether <body> exists yet or whether document.body is null.

Of the modern alternatives, document.body.appendChild has the edge case. When the loader runs in <head>, <body> hasn’t been parsed yet, so document.body is null. document.head.appendChild almost always works on an HTML page: even if the author left out <head>, the parser creates the head element before the first script runs. (It is null mainly in XHTML written without a <head>.) Making b the first <script> in the document (there is always at least one: the loader itself) and inserting with insertBefore(a, b) spares you from thinking about any of these cases.

Figure: the DOM at the moment the loader runs in the head, and two ways to insert the body script. Title: when the loader runs in head, there is no body yet. Left, the DOM while the loader runs: html contains head, and head holds the running loader script (in this example, the first script) with the spot just before it where the body goes. body is not parsed yet. If another script comes before the loader, the body goes in front of that one. Top right, document.body.appendChild(a): document.body is null, so it throws a TypeError; with the loader in head, it stops here. Bottom right, taking the first result of getElementsByTagName('script') as b and calling b.parentNode.insertBefore(a, b): the body lands under the first script's parent and works anywhere, because the loader itself is in the DOM so b always exists. Takeaway: whether the loader sits in head or at the end of body, there is always a place to insert. Basis: HTML Standard (document.body is null without a body element); the code is the last two lines of youtube.com/iframe_api

What to steal for your own SDK

Seven operational upsides in one 993-byte file:

  1. Version your body URL with a hash; keep the loader URL stable. One URL customers pin, one URL you rotate.
  2. Cache the loader for zero seconds, the body for a year. Repeat visits pay the loader’s small revalidation only.
  3. Wrap script URLs in a Trusted Types policy with a try/catch fallback. Cheap now, essential when a customer’s CSP tightens.
  4. Guard against double-load from the very first commit. Tag managers will fire your snippet twice.
  5. Define your .ready() queue in the loader, not the body. Callers who arrive before the body script must not hit an undefined function.
  6. Copy document.currentScript.nonce onto the injected <script>. Strict CSPs are increasingly common; this is the compatibility fix.
  7. Insert the injected <script> before the first <script> on the page. You never need document.head or document.body to exist yet.

The two-stage split is the foundation the other six patterns stand on. If your SDK is one monolithic file, you can’t do half of these. If it’s split, the loader gets small enough that annotating every byte becomes viable. That is what let YouTube ship a loader you can read end to end in ten minutes.

The same split shows up at a much smaller scale. When I added a support overlay across my own product pages, the injected tag was one line; the UI, the analytics and the per-URL rules all lived in a single file on the main site, so a redesign never touches the products. That build is written up in Ship a product, get a support button for free.


This is part 1 of a series teardown of five real script-tag JS SDKs (YouTube, Safie, Vimeo, Mux Player, Video.js). See the series pillar for the full table of contents. Measured 2026-08-08 against build 854a788e.