An Embed Is Not a Component

The page is 14KB of HTML and one stylesheet. No client-side framework, no hydration, nothing to parse. It renders off a CDN edge in under 200ms and the Lighthouse run is boring in the best possible way.
Then marketing needs the product video on the homepage, support wants the chat widget back, and someone adds a booking calendar to the contact page. Three lines of copy-pasted markup. The page is now well over a megabyte across dozens of requests, most of it JavaScript from four hostnames, and the main thread is busy long after the content is already visible.
Nobody wrote a line of application code. That’s what makes this hard to argue about internally — the diff looks trivial.
What you actually merged
You think of an embed as a component because it arrives shaped like one: a tag, some attributes, a rendered box. But a component is something your build knows about. It has a version in your lockfile, it lands in a bundle you can measure, and if it breaks, it breaks at build time in front of you.
An embed has none of those properties. It is a second website, fetched at runtime, with a dependency tree you did not resolve, a version you cannot pin, and content that changes without a rebuild. Your static site’s entire value proposition is that everything was decided before the request arrived. An embed moves that decision back to runtime and hands it to a vendor.
Four specific things you give up:
- Build-time determinism. Your HTML is byte-stable across builds. The embed’s payload is not, and it can change at any time without a deploy on your side.
- Your performance budget. You do not control the embed’s bundle size, its caching headers, or how many hosts it fans out to.
- Failure isolation. If the vendor is slow, your page is slow. If they are down, your page has a dead rectangle in it.
- Your security boundary. This one depends entirely on which kind of embed it is, and the distinction matters more than anything else in this post.
The only distinction that matters: iframe or script
There are two kinds of embed and they are not comparable. People discuss them as if they were both “adding a widget.”
An <iframe> embed loads the third party into a separate document with its own origin. It cannot read your DOM, your cookies, or your localStorage. It can be sandboxed further, it can be lazy-loaded, and it can be sized and contained. It costs you bytes and requests, and that is roughly the whole bill.
<iframe
src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
title="Product overview"
width="560"
height="315"
loading="lazy"
referrerpolicy="no-referrer"
allow="encrypted-media; picture-in-picture"
allowfullscreen
></iframe>
A <script src> embed is a completely different transaction. It executes in your document, in your origin, with full access to everything on the page: the DOM, form fields as the user types into them, first-party cookies not marked HttpOnly, and localStorage. It can inject more scripts from more hosts. It can mutate your markup after render.
<!-- This is not a widget. This is production access. -->
<script src="https://widget.example.com/loader.js" async></script>
Adding a script embed is functionally equivalent to giving a vendor commit rights to your production frontend, with no review, no changelog, and the ability to push whenever they like. That is not a hypothetical framing — it is the exact mechanism behind the third-party script compromises that keep hitting checkout pages.
The rule is short: prefer an iframe. If the vendor only ships a script, treat it as a vendor security decision, not a frontend one.
Lazy is not the same as cheap
loading="lazy" on an iframe is genuinely good and you should use it on every embed below the fold. It is broadly supported, costs nothing, and defers the entire subresource tree until the frame nears the viewport.
But it only defers. The moment that iframe enters the viewport, a video player pulls hundreds of kilobytes of JavaScript, plus player assets and API calls on top. If your embed is above the fold — which the marketing video always is — loading="lazy" does nothing for you at all.
The fix for above-the-fold embeds is a facade: render a static, cheap approximation of the embed at build time, and load the real one only when someone interacts with it.
<div class="video-facade" data-video-id="VIDEO_ID">
<img
src="/media/product-overview-poster.webp"
alt="Product overview"
width="1280"
height="720"
/>
<button type="button" aria-label="Play product overview">Play</button>
</div>
document.querySelectorAll('.video-facade').forEach((facade) => {
const button = facade.querySelector('button');
button.addEventListener(
'click',
() => {
const frame = document.createElement('iframe');
frame.src = `https://www.youtube-nocookie.com/embed/${facade.dataset.videoId}?autoplay=1`;
frame.title = 'Product overview';
frame.allow = 'autoplay; encrypted-media; picture-in-picture';
frame.allowFullscreen = true;
facade.replaceChildren(frame);
},
{ once: true }
);
});
That is about twenty lines and no dependency. The poster image is generated at build time from your own media pipeline, so it is optimized, cached on your CDN, and covered by your existing image rules. The visitor who never clicks play — the large majority — pays for one WebP.
The same shape works for chat widgets (a real button that boots the widget on click), maps (a static tile image linking out), and comment systems (a placeholder that loads on scroll into view).
Every embed is a hole in your CSP
If you have done the work to ship a strict Content Security Policy — and on a static site that work is unusually easy — each embed arrives with a list of exceptions it demands. A single video embed typically means allowing a player origin in frame-src, an image host in img-src, and often a stats or config host in connect-src.
Add a chat widget and a booking tool and your policy stops being a policy. It becomes a list of companies you have decided to trust unconditionally, plus whatever hosts they decide to call next quarter.
Write the policy first and let embeds fail against it. A CSP violation in staging is a design conversation. Loosening the policy quietly in the same PR that adds the widget is how you end up with script-src 'unsafe-inline' https: two years later.
Gate it in CI
The reason embeds accumulate is that adding one is invisible in review. Make it visible. Since your output is static HTML sitting in a directory, you can just read it.
// scripts/check-third-party.mjs
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ALLOWED = new Set(['www.youtube-nocookie.com']);
const HOST = /(?:src|href)="https?:\/\/([^/"]+)/g;
function walk(dir) {
return readdirSync(dir).flatMap((entry) => {
const full = join(dir, entry);
return statSync(full).isDirectory() ? walk(full) : [full];
});
}
const offenders = [];
for (const file of walk('dist').filter((f) => f.endsWith('.html'))) {
const html = readFileSync(file, 'utf8');
for (const [, host] of html.matchAll(HOST)) {
if (!ALLOWED.has(host)) offenders.push(`${file}: ${host}`);
}
}
if (offenders.length) {
console.error('Unapproved third-party origins in build output:');
console.error([...new Set(offenders)].join('\n'));
process.exit(1);
}
Run it after the build and wire it into the same CI job as your other gates:
npm run build && node scripts/check-third-party.mjs
Now adding an embed requires editing an allowlist, which means someone has to type the vendor’s hostname into a file with a git blame on it. That single friction point does more than any performance budget document.
The ones you can just delete
Before optimizing an embed, check whether it needs to exist. A surprising number are load-bearing for nobody:
- Social post embeds. A blockquote with the text and a link is faster, more accessible, more readable, and survives the post being deleted.
- Map embeds. Most visitors want the address and a route. A static map image wrapped in a link to the mapping app does that in a few kilobytes.
- Font and icon CDNs. Self-host. The shared-cache argument died when browsers partitioned their HTTP caches by top-level site — every visitor downloads it fresh from you anyway, just slower and from someone else’s domain.
- Chat widgets on marketing pages. Check the actual conversation volume against the bytes. It is very often an enormous annual byte cost for a handful of chats a week, most of which would have arrived by email anyway.
The takeaway
Static sites do not get slow gradually. They get slow in a single commit, when someone pastes in markup that looks like a component and is in fact a runtime dependency on a company you have no contract with.
Three rules, in order:
- Iframe over script, always. A script embed is a production access grant, and should be reviewed like one.
- Facade anything above the fold. A build-time poster plus twenty lines of vanilla JS beats every “lightweight” embed wrapper on npm.
- Put the third-party origin list in a file that CI enforces. If adding an embed does not require an approved diff, you will have nine of them by next year and no idea which ones are still needed.
You built a site where everything was decided at build time. Do not hand the last word to a <script> tag you did not write.

