Go to almost any blog or ISP comparison site and you'll find a "Check your speed" button. Click it. Open DevTools. Watch what happens.
On a typical site last week, the button pulled in 347KB of JavaScript before the first byte of the test even ran — a forked copy of an older Ookla wrapper, a full charting library, a DOM framework, and three third-party trackers that phone home the moment the gauge starts moving. The actual measurement was maybe 40 lines of code buried underneath all of it.
This is the state of embeddable speedtest widgets in 2026: heavy, opaque, and leaky. I wanted one for a comparison site and wasn't willing to ship any of that. So I wrote a drop-in that does a real Cloudflare-backed measurement, renders into a Shadow DOM, animates an SVG gauge, and ships at 6.3KB gzipped with zero runtime dependencies and zero network calls that aren't the test itself.
This post is the technical writeup: the architecture, the four patterns that do the heavy lifting, and the benchmark that made me throw the alternatives away.
Why the existing options are bad (with receipts)
Before building anything, I audited what's out there. The numbers aren't flattering:
| Option | Bundle (gz) | Dependencies | Talks to |
|---|---|---|---|
| Generic Ookla embed wrapper | ~210KB | jQuery + custom SDK | Ookla + 2 ad trackers |
| fast.com embed (reverse-eng. clone) | ~140KB | React + charting lib | Netflix telemetry |
| Popular "free speedtest" WP plugin | ~180KB | jQuery + 3 plugins | Vendor + analytics |
| This widget | 6.3KB | none | speed.cloudflare.com only |
Three things show up in every alternative:
- A charting library to draw one line. A speedtest shows a single sparkline. You do not need D3, Chart.js, or Recharts for that. It's 40 SVG points.
- A framework to render one card. The entire UI is a ring, a number, and a button. That's not a React problem.
- Third-party calls that aren't the test. Analytics, ad pixels, "telemetry." A user came to measure their connection, not to be measured themselves.
The 6.3KB isn't a stunt — it's just what happens when you delete everything that isn't the measurement and the UI around it.
The architecture: one IIFE, one Shadow root, no framework
┌─ Host page (any site, any framework) ──────────────┐
│ │
│ <div id="nevik-speedtest"></div> │
│ <script src="speedtest-widget.js" async></script> │
│ │
│ ┌─ Shadow DOM (open) ─────────────────────────────┐│
│ │ <style> /* fully scoped, can't leak */ ││
│ │ <div class="widget"> ││
│ │ SVG gauge · sparkline · button · results ││
│ │ </div> ││
│ └──────────────────────────────────────────────────┘│
│ │
│ fetch() ──► speed.cloudflare.com (CORS: *) │
└──────────────────────────────────────────────────────┘
The whole thing is a single IIFE. It finds its mount element, attaches a Shadow root, injects scoped styles and markup, and waits for a click. No build step, no npm install, no version pinning. The host page's CSS — even aggressive resets like * { margin: 0 !important; } — cannot reach inside, because Shadow DOM boundaries block inheritance and selector matching.
var host = document.getElementById('nevik-speedtest');
var shadow = host.attachShadow({ mode: 'open' });
var styleEl = document.createElement('style');
styleEl.textContent = STYLES(true); // ':host{all:initial}' + scoped rules
shadow.appendChild(styleEl);
var wrap = document.createElement('div');
wrap.className = 'nvk-widget';
wrap.innerHTML = HTML();
shadow.appendChild(wrap);
Why Shadow DOM and not an iframe? An iframe gives you isolation too, but you pay for a second document, a second event loop, CORS friction, and a fixed coordinate system that breaks responsive layout. Shadow DOM gives you the isolation for free on the page you already have. Stripe's Payment Element and Trustpilot's embed both use this approach; iframes are the heavier Calendly/Typeform path.
The :host{all:initial} at the top of the scoped stylesheet is the load-bearing detail. It resets every inherited property on the shadow host before applying widget styles, so a host page with body { font-family: Comic Sans } can't leak typography into the widget.
Highlight 1 — Multi-stream download (the accuracy trick)
This is the part most "I built a speedtest" posts get wrong. A single HTTP download is a terrible way to measure throughput on a fast connection, because of TCP slow-start. A fresh connection ramps its congestion window over several seconds before it hits the link's actual capacity. If your test downloads 10MB on one connection, a large fraction of that transfer happens during the ramp-up — and your measured speed is half the real number.
This is why speedtest.net opens 4–8 parallel connections. Each one still slow-starts, but their aggregate throughput saturates the link much faster. I do the same thing:
var PARALLEL_STREAMS = 6; // speedtest.net uses 4–8
var DOWNLOAD_SIZE_PER_STREAM = 15000000; // 15MB per stream → 90MB total
async function downloadStream(streamId, totalBytesRef, onProgress) {
var resp = await fetch(
CF_DOWN + DOWNLOAD_SIZE_PER_STREAM + '&tid=' + streamId + '_' + Math.random(),
{ cache: 'no-store' }
);
var reader = resp.body.getReader();
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
totalBytesRef.val += chunk.value.length;
onProgress();
}
}
async function measureDownload(onProgress) {
var totalBytesRef = { val: 0 };
var tStart = performance.now();
var streams = [];
for (var i = 0; i < PARALLEL_STREAMS; i++) {
streams.push(downloadStream(i, totalBytesRef, function () {
var elapsed = (performance.now() - tStart) / 1000;
if (elapsed > 0.05) { // ignore the first 50ms (TCP ramp + fetch overhead)
var mbps = (totalBytesRef.val * 8 / 1000000) / elapsed;
onProgress(mbps);
}
}));
}
await Promise.all(streams);
var elapsed = (performance.now() - tStart) / 1000;
return (totalBytesRef.val * 8 / 1000000) / elapsed;
}
Three details matter here:
-
totalBytesRefis a shared object, not a number. Six async closures need to mutate the same accumulator. A barelet totalByteswould work in modern JS, but passing a reference object makes the shared-state intent explicit and works in every browser. -
The
elapsed > 0.05guard. The very first chunks arrive during TCP ramp-up and fetch connection setup. Dividing tiny byte counts by tiny elapsed times produces wild spikes (300+ Mbps phantom readings). Ignoring the first 50ms kills the noise. -
Streaming the reader, not buffering the response. Reading
resp.body.getReader()chunk-by-chunk is what lets the gauge climb during the test instead of jumping to a final number at the end. This is the fast.com feel.
The upload side does the same trick with three parallel POSTs of a 10MB random buffer — multi-stream for the same slow-start reason.
Highlight 2 — The SVG gauge with a pulsing glow
The gauge looks like one element but is three concentric SVG circles: a dim track, a blurred glow halo, and the sharp fill on top.
<svg viewBox="0 0 200 200">
<circle class="nvk-gauge-ring" cx="100" cy="100" r="76"/>
<circle class="nvk-gauge-glow" cx="100" cy="100" r="76"/>
<circle class="nvk-gauge-fill" cx="100" cy="100" r="76"/>
</svg>
The fill and glow are driven by stroke-dasharray / stroke-dashoffset — the classic Activity Ring trick. You set the dash array to the full circumference, then animate the offset from full (empty ring) down toward zero (full ring):
var RADIUS = 76;
var CIRC = 2 * Math.PI * RADIUS; // ≈ 477.5
gaugeFill.style.strokeDasharray = CIRC;
gaugeFill.style.strokeDashoffset = CIRC; // start empty
function setGauge(pct, color) {
var offset = CIRC * (1 - pct);
gaugeFill.style.strokeDashoffset = offset;
if (color) {
gaugeFill.style.stroke = color;
gaugeGlow.style.stroke = color; // glow tracks the fill color
}
}
The glow is the same circle, stroke-width: 16, filter: blur(8px), normally invisible (opacity: 0). While the test runs it pulses via a CSS keyframe animation, so the ring "breathes" — a small signal that work is happening:
.nvk-gauge-glow {
fill: none;
stroke: var(--accent);
stroke-width: 16;
opacity: 0;
filter: blur(8px);
transition: opacity .3s;
}
.nvk-gauge.measuring .nvk-gauge-glow {
opacity: .4;
animation: nvk-pulse 1.5s ease-in-out infinite;
}
@keyframes nvk-pulse {
0%, 100% { opacity: .3; }
50% { opacity: .6; }
}
The color also reflects the result: green ≥ 50 Mbps, amber ≥ 16 Mbps, red below. So the gauge isn't decorative — it encodes the verdict.
The number inside the ring isn't a static text node. It eases up to its target with an ease-out cubic curve (1 - (1-t)³), driven by requestAnimationFrame:
function animateSpeed(target, color) {
var start = displaySpeed;
var startTime = performance.now();
var duration = 400;
function frame(now) {
var t = Math.min((now - startTime) / duration, 1);
var eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
displaySpeed = start + (target - start) * eased;
speedNum.textContent = fmtSpeed(displaySpeed);
setGauge(Math.min(displaySpeed / 1000, 1), color);
if (t < 1 && running) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
Ease-out cubic is the curve Stripe, Vercel, and Linear all use for number counters — it snaps forward and decelerates, which reads as "responsive" rather than "animated." Linear easing reads as cheap; ease-in reads as laggy. The choice of curve is the whole difference between premium and student project.
Highlight 3 — Container Queries for responsive embedding
A widget has to fit wherever the host puts it: a 380px sidebar, a 280px mobile column, a 600px hero. Historically you'd do this with a ResizeObserver and JS measurement, or just give up and set a min-width and overflow-scroll.
Container queries solve this in pure CSS. The widget container declares itself a query container:
.nvk-widget {
container-type: inline-size;
container-name: widget;
max-width: 380px;
width: 100%;
}
…and then styles can respond to the container's size, not the viewport's:
@container widget (max-width: 340px) {
.nvk-gauge { width: 160px; height: 160px; }
.nvk-speed-num { font-size: 2.1rem; }
.nvk-head .title { font-size: 1rem; }
.nvk-sparkline-wrap { width: 160px; }
}
Drop the same <div> into a 300px mobile column and the gauge shrinks, the number resizes, the sparkline narrows — no JavaScript, no observer, no layout thrash. The host page's breakpoints don't matter because the container is its own layout context.
Container queries are baseline-supported in every evergreen browser since 2023. There's no longer a reason to reach for ResizeObserver for component-level responsiveness.
Highlight 4 — The magnetic button (the Stripe/Framer micro-interaction)
This is the detail that makes the widget feel expensive for the ~20 lines it costs. On hover, the button drifts a few pixels toward the cursor; on leave, it springs back with a slight overshoot.
if (!reducedMotion) {
btn.addEventListener('pointermove', function (e) {
if (btn.disabled) return;
var rect = btn.getBoundingClientRect();
var x = e.clientX - rect.left - rect.width / 2;
var y = e.clientY - rect.top - rect.height / 2;
btn.style.transform = 'translate(' + (x * 0.12) + 'px,' + (y * 0.12) + 'px)';
});
btn.addEventListener('pointerleave', function () {
btn.style.transition = 'transform 400ms cubic-bezier(0.34,1.56,0.64,1)';
btn.style.transform = 'translate(0,0)';
setTimeout(function () { btn.style.transition = ''; }, 400);
});
}
Two things make it work:
-
0.12multiplier. The button moves at 12% of the cursor's offset from center. Enough to feel alive, not enough to feel drunk. Framer's open-source site uses roughly this ratio. -
cubic-bezier(0.34, 1.56, 0.64, 1)on the return. The1.56overshoot past 1.0 is what gives the spring. A plainease-outreturn feels dead; the overshoot is the whole trick. This is the same curve Framer Motion ships as its defaultspring-ish back-ease.
Pair this with the Stripe-style flashlight glow — a radial-gradient on ::after whose center tracks CSS custom properties set on pointermove — and the card reads as a polished product surface, not a form control:
.nvk-widget {
--x: -999px;
--y: -999px;
position: relative;
overflow: hidden;
}
.nvk-widget::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
transition: opacity .35s ease;
background: radial-gradient(
180px circle at var(--x) var(--y),
rgba(0, 232, 168, 0.2),
transparent 50%
);
pointer-events: none;
}
.nvk-widget.flash-active::after { opacity: 1; }
widgetEl.addEventListener('pointermove', function (e) {
var rect = widgetEl.getBoundingClientRect();
widgetEl.style.setProperty('--x', (e.clientX - rect.left) + 'px');
widgetEl.style.setProperty('--y', (e.clientY - rect.top) + 'px');
});
No rAF loop, no per-frame work — just a CSS transition on opacity toggled by a class. The cursor sets two CSS variables; the gradient reads them. Costs essentially nothing.
Accessibility note: all of this is gated behind window.matchMedia('(prefers-reduced-motion: reduce)').matches. If the user has requested reduced motion, the magnetic and flashlight effects are skipped entirely and the widget falls back to plain hover states. The number counter also snaps to its final value instead of animating. Non-negotiable.
The benchmark: 6KB vs the field
This is the part I kept looking for and couldn't find, so here it is. Measured by downloading each bundle over a clean connection and running it through gzip -9:
| Widget | Raw JS | Gzipped | Calls home to |
|---|---|---|---|
| Popular Ookla-wrapper embed | 612KB | 211KB | Ookla + 2 trackers |
| fast.com clone (React) | 430KB | 138KB | Netflix telemetry |
| WP "free speedtest" plugin | 540KB | 182KB | Vendor + GA |
| This widget | 23KB | 6.3KB | Cloudflare only |
That's a 30–33× reduction in transferred bytes. On a mobile connection at the edge of coverage — exactly the situation where someone is checking their speed — that difference is 1–2 extra seconds of perceived load time and a measurable chunk of data against a capped plan.
The widget also makes exactly the network calls required to run the test and nothing else: speed.cloudflare.com/__down and /__up, both of which return Access-Control-Allow-Origin: * and are explicitly public endpoints. No analytics endpoint, no telemetry beacon, no ad pixel. The number you see is computed from bytes the Cloudflare edge actually sent you.
Try it / embed it
Live demo: dsl.nevik.de/widgets/
Copy-paste embed (two lines, no build, no npm):
<div id="nevik-speedtest"></div>
<script src="https://dsl.nevik.de/embed/speedtest-widget.js" async></script>
Options via data attributes:
<div id="nevik-speedtest"
data-theme="light"
data-width="380"
data-pid="299075">
</div>
It's MIT-licensed. The full source is the single file linked above — read it, fork it, strip the CTA if you don't want it. There is no build step and no hidden bundle; what you see is what ships.
What I'd still change
Honest gaps, so this isn't a sales pitch:
-
Cloudflare rate-limits the public endpoints. For a high-traffic production deployment you'd want to proxy through your own
/__down//__upand cache the CORS preflight. The widget already accepts any endpoint URL — swap the two constants. - The upload measurement is the rough part. Three parallel 10MB POSTs is good enough for a consumer widget, not for lab-grade measurement. Ookla uses configurable payload sizes and more streams; I chose fewer for the bandwidth cost on mobile.
-
Shadow DOM is
mode: 'open'. A closed root would be more tamper-resistant, but open lets the demo page (and curious developers) inspect the internals in DevTools. For a transparent open-source widget that's the right tradeoff.
The takeaway
Most "embeddable" widgets are bloated for the same reason most SPAs are bloated: the defaults are heavy and removing things feels like work. A speedtest is an unusually clean case study because the actual job — open N connections, count bytes, divide by time — is tiny, and everything around it is where the bytes go.
The patterns above aren't novel individually. Shadow DOM, container queries, stroke-dashoffset gauges, and magnetic buttons are all documented techniques. What's rare is using all of them together in 6KB and refusing the dependency that would double the size for a feature you don't need. The craft is in the deletion.
If you're shipping an embeddable widget, the checklist that fell out of this:
- One file, one IIFE, zero dependencies. If you can't grep the whole codebase in one screen, it's too big.
-
Shadow DOM with
:host{all:initial}. Isolation isn't optional for embeds. - Container queries, not JS observers. The browser already knows its size.
- Multi-stream the measurement. Single-stream throughput numbers lie on fast links.
-
Gate every animation behind
prefers-reduced-motion. Not optional either. - Make exactly the network calls the feature requires. Every other call is a tax on the user's trust.
I'm curious what others do for embeddable-widget isolation — Shadow DOM, iframe, or something else? And what's the smallest useful widget you've shipped?
Top comments (0)