DEV Community

Nevik Schmidt
Nevik Schmidt

Posted on

I built a 6KB internet speedtest widget with zero dependencies — here's how

I needed a speedtest for a comparison site. Every existing option either costs money, weighs 200KB+, requires an iframe that breaks CORS, or injects ads. So I built one in vanilla JS that runs a real measurement via Cloudflare's public speedtest endpoints, renders in a Shadow DOM bubble, and ships at 6.3KB gzipped.

Here's what I learned and the patterns that made it work.

The architecture in one diagram

┌─ Host page (any website) ────────────────────┐
│                                                │
│  <div id="nevik-speedtest"></div>              │
│  <script src="speedtest-widget.js" async>      
                                                
  ┌─ Shadow DOM (open) ──────────────────────┐ 
    <style> /* scoped CSS */                  
    <div class="widget">                      
      <svg> gauge, button, sparkline         
    </div>                                   │ │
│  └────────────────────────────────────────────┘ 
                                                
  fetch()  speed.cloudflare.com (CORS: *)     
└────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The widget is a self-contained IIFE that finds its mount point, creates a Shadow DOM root, injects scoped styles and markup, then waits for a click.

Why Shadow DOM and not an iframe?

  • No CORS headaches (the host page's fetch works fine)
  • CSS doesn't leak in or out
  • document.querySelector() on the host page can't see inside
  • Smaller bundle, no second document overhead

Stripe's Payment Element and Trustpilot both do this. Iframes are the Calendly/Typeform approach — easier but heavier and harder to theme.

The speedtest engine (the part that actually measures)

Cloudflare runs a public speedtest at speed.cloudflare.com. The endpoints are:

// Download: fetch N bytes, measure throughput
const CF_DOWN = 'https://speed.cloudflare.com/__down?bytes=';
const downloadSize = 10_000_000; // 10MB
const resp = await fetch(CF_DOWN + downloadSize + '&tid=' + Math.random(), {
  cache: 'no-store'
});

// Upload: POST a buffer, measure throughput  
const CF_UP = 'https://speed.cloudflare.com/__up';
const payload = new Uint8Array(5_000_000);
await fetch(CF_UP, { method: 'POST', body: payload, cache: 'no-store' });
Enter fullscreen mode Exit fullscreen mode

Both send Access-Control-Allow-Origin: *, so they work from any origin.

Streaming the download for live progress

The trick to a live-updating speedometer isn't to measure the whole download and divide — it's to stream it:

async function measureDownload(onProgress) {
  const sizes = [3_000_000, 10_000_000, 15_000_000];
  let totalBytes = 0;
  const tStart = performance.now();

  for (const size of sizes) {
    const resp = await fetch(CF_DOWN + size + '&tid=' + Math.random(), {
      cache: 'no-store'
    });
    const reader = resp.body.getReader();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      totalBytes += value.length;

      const elapsed = (performance.now() - tStart) / 1000;
      const mbps = (totalBytes * 8 / 1_000_000) / elapsed;
      onProgress(mbps); // live callback → updates the gauge
    }
  }

  const elapsed = (performance.now() - tStart) / 1000;
  return (totalBytes * 8 / 1_000_000) / elapsed;
}
Enter fullscreen mode Exit fullscreen mode

Reading resp.body.getReader() lets you count bytes as they arrive and update the UI every chunk. This is how fast.com shows a number that climbs during the test.

Ping with outlier rejection

Ping is just latency to the same endpoint:

async function measurePing() {
  const times = [];
  for (let i = 0; i < 6; i++) {
    const t0 = performance.now();
    await fetch(CF_DOWN + '1000&tid=' + Math.random(), { cache: 'no-store' });
    times.push(performance.now() - t0);
  }
  times.sort((a, b) => a - b);
  times.pop(); // discard the worst outlier
  return Math.round(times.reduce((s, v) => s + v, 0) / times.length);
}
Enter fullscreen mode Exit fullscreen mode

Six samples, drop the worst, average the rest. Simple and stable.

The SVG gauge (a ring that fills up)

The gauge is two SVG circles — a dim track and a bright fill:

<svg viewBox="0 0 200 200">
  <circle class="track" cx="100" cy="100" r="76"
          fill="none" stroke="#30363d" stroke-width="8"/>
  <circle class="fill" cx="100" cy="100" r="76"
          fill="none" stroke="#00e8a8" stroke-width="8"
          stroke-linecap="round"/>
</svg>
Enter fullscreen mode Exit fullscreen mode

The fill is controlled via stroke-dasharray and stroke-dashoffset:

const RADIUS = 76;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; // ≈ 477.5

// Set the total length
fillCircle.style.strokeDasharray = CIRCUMFERENCE;

// Fill to X percent:
function setGauge(percent) {
  const offset = CIRCUMFERENCE * (1 - percent);
  fillCircle.style.strokeDashoffset = offset;
}
Enter fullscreen mode Exit fullscreen mode

This is the same pattern Apple uses for Activity Rings. The SVG is rotated -90° so the fill starts at 12 o'clock.

Animated number counter (the detail that makes it feel alive)

Static numbers feel dead. A counter that eases up to the final value feels responsive and premium:

function animateSpeed(target, color) {
  const start = displaySpeed;
  const startTime = performance.now();
  const duration = 400;

  function frame(now) {
    const t = Math.min((now - startTime) / duration, 1);
    const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
    displaySpeed = start + (target - start) * eased;
    speedNum.textContent = formatSpeed(displaySpeed);
    setGauge(Math.min(displaySpeed / 1000, 1));
    if (t < 1) requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
Enter fullscreen mode Exit fullscreen mode

The key is the easing function: 1 - Math.pow(1 - t, 3). It starts fast and decelerates, which feels snappy without being jarring. This is the standard easing Stripe, Vercel, and Linear use for their number displays.

The live sparkline (a tiny chart during measurement)

While the download runs, I push speed samples to an array and render them as an SVG polyline:

const sparkData = [];

function pushSpark(speed) {
  sparkData.push(speed);
  if (sparkData.length > 40) sparkData.shift();
  if (sparkData.length < 2) return;

  const max = Math.max(...sparkData, 1);
  const w = 200, h = 40;
  const points = sparkData
    .map((v, i) => {
      const x = (i / (sparkData.length - 1)) * w;
      const y = h - (Math.min(v / max, 1) * h * 0.85) - 4;
      return `${x.toFixed(1)},${y.toFixed(1)}`;
    })
    .join(' ');

  polyline.setAttribute('points', points);
}
Enter fullscreen mode Exit fullscreen mode

Cloudflare's own speedtest shows a line graph during measurement. This is the mini version — a 200×40px sparkline that updates every ~50ms. No charting library needed.

The "Flashlight" glow (cursor-tracking radial gradient)

This is the pattern that makes the widget feel expensive. It's a radial gradient that follows the cursor:

.widget {
  --x: -999px;
  --y: -999px;
  position: relative;
  overflow: hidden;
}

.widget::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  opacity: 0;
  transition: opacity 0.35s ease;
  background: radial-gradient(
    180px circle at var(--x) var(--y),
    rgba(0, 232, 168, 0.2),
    transparent 50%
  );
  pointer-events: none;
}

.widget.flash-active::after {
  opacity: 1;
}
Enter fullscreen mode Exit fullscreen mode
widget.addEventListener('pointermove', (e) => {
  const rect = widget.getBoundingClientRect();
  widget.style.setProperty('--x', `${e.clientX - rect.left}px`);
  widget.style.setProperty('--y', `${e.clientY - rect.top}px`);
});
widget.addEventListener('pointerenter', () => {
  widget.classList.add('flash-active');
});
widget.addEventListener('pointerleave', () => {
  widget.classList.remove('flash-active');
});
Enter fullscreen mode Exit fullscreen mode

I lifted this directly from Stripe's card border effect on stripe.com/payments/elements. The cursor sets CSS variables, the gradient reads them. No JavaScript animation loop, just a CSS transition on opacity. Costs essentially nothing in performance.

Use pointermove not mousemove so it works on touch devices too.

Shadow DOM: the containment that matters

The widget mounts into a Shadow DOM so the host page can't accidentally break it:

const host = document.getElementById('nevik-speedtest');
const shadow = host.attachShadow({ mode: 'open' });

// Styles inside shadow are scoped — they can't leak out
const style = document.createElement('style');
style.textContent = widgetCSS;
shadow.appendChild(style);

// Markup lives in shadow — host page's CSS can't touch it
const wrap = document.createElement('div');
wrap.className = 'widget';
wrap.innerHTML = widgetHTML;
shadow.appendChild(wrap);
Enter fullscreen mode Exit fullscreen mode

This means even if the host page has * { margin: 0 !important; } or weird global resets, the widget is untouched. Stripe and Trustpilot do the same thing.

What I'd do differently

  1. Cloudflare rate limits the endpoints. For a production widget on a high-traffic site, you'd want your own download/upload endpoints as a fallback.
  2. The upload measurement is rough. A single 5MB POST isn't as accurate as the multi-file approach Cloudflare uses internally. Good enough for a consumer widget, not for lab-grade measurement.
  3. Shadow DOM mode: 'closed' would be more secure (host JS can't read internals), but I went with 'open' so the widget is debuggable and the demo page can inspect it.

The result

  • 6.3KB gzipped, zero dependencies
  • Real measurement via Cloudflare (not a fake animation)
  • Shadow DOM isolated — works on any page without conflicts
  • Works in one line: <div id="nevik-speedtest"></div> + script tag
  • Dark/light themes, respects prefers-reduced-motion
  • Live demo and embed code here

The full source is MIT-licensed. If you need a speedtest on your site without the bloat, give it a try.


Have you built embeddable widgets before? What patterns do you use for isolation — Shadow DOM, iframes, or something else?

Top comments (0)