Ich habe einen echten Speedtest in Vanilla JS gebaut (mit Cloudflare API)

Kein npm install. Kein React. Kein 200-MB-node_modules-Ordner. Nur HTML, CSS und ~250 Zeilen JavaScript, die deine echte Internetgeschwindigkeit messen.

👉 Live-Demo: dsl.nevik.de/speedtest


Warum noch ein Speedtest?

Es gibt Speedtest.net, FAST.com und dutzende andere. Warum also selbst bauen? Drei Gründe:

  1. Transparenz: Ich wollte genau verstehen, was gemessen wird – und was nicht.
  2. Größe: Die meisten kommerziellen Speedtests laden mehrere MB an Tracking-Scripts. Meiner ist eine einzige HTML-Datei mit eingebettetem JS.
  3. Kontrolle: Ich kann das Ergebnis direkt gegen den gebuchten Tarif des Nutzers bewerten und eine fundierte Empfehlung geben.

Das Ergebnis ist ein Speedtest, der in unter 50 KB ausgeliefert wird, auf jedem Gerät läuft und echte Messwerte liefert – keine Schätzwerte.


Die Architektur: Drei Phasen, drei Messungen

Ein guter Speedtest misst drei Dinge:

  1. Ping (Latenz): Wie schnell kommt ein Datenpaket hin und zurück?
  2. Download: Wie schnell kommen Daten bei dir an?
  3. Upload: Wie schnell kommen Daten von dir raus?

Für alle drei nutze ich die öffentliche Cloudflare-Speedtest-API, die unter speed.cloudflare.com läuft. Cloudflare betreibt eines der größten Edge-Netzwerke der Welt, hat Server in praktisch jedem Land und – ganz wichtig – erlaubt CORS für diese Endpunkte, sodass wir direkt aus dem Browser heraus messen können.

Die zwei Endpunkte, die alles tragen:

const CF_DOWN = 'https://speed.cloudflare.com/__down?bytes=';
const CF_UP   = 'https://speed.cloudflare.com/__up';

Enter fullscreen mode Exit fullscreen mode

  • __down?bytes=N liefert exakt N Bytes zurück.
  • __up nimmt einen POST-Body beliebiger Größe entgegen.

Das war's. Kein API-Key, keine Rate-Limits, die für unsere Zwecke relevant wären, keine Kosten.


Phase 1: Ping messen (ohne WebSocket)

Klassische Speedtests nutzen für den Ping oft WebSockets oder RTCPeerConnection-Tricks. Das ist komplex und fehleranfällig. Mein Ansatz: Wir laden einfach einen winzigen Datenblock (1 KB) fünfmal und messen die Round-Trip-Time mit performance.now().

async function measurePing() {
  const times = [];
  for (let i = 0; i < 5; i++) {
    const t0 = performance.now();
    try {
      await fetch(CF_DOWN + '1000&tid=' + Math.random(), { cache: 'no-store' });
      times.push(performance.now() - t0);
    } catch (e) { /* ignore single failure */ }
  }
  if (times.length === 0) throw new Error('Ping-Messung fehlgeschlagen');

  times.sort((a, b) => a - b);
  // discard highest, average the rest
  const valid = times.slice(0, Math.max(1, times.length - 1));
  return Math.round(valid.reduce((s, v) => s + v, 0) / valid.length);
}

Enter fullscreen mode Exit fullscreen mode

Warum der ganze Aufwand?

  • cache: 'no-store' verhindert, dass der Browser den Request aus dem Cache bedient – sonst wären die Zeiten unrealistisch schnell.
  • tid=Math.random() ist ein Cache-Buster, falls ein Proxy zwischengreift.
  • Höchsten Wert verwerfen: Ein einzelner Ausreißer (z. B. durch TCP-Handshake beim ersten Request) verfälscht sonst den Mittelwert.

Das ist kein „echter" ICMP-Ping (den kann der Browser nicht messen), sondern ein HTTP-RTT. Für die Bewertung der Nutzererfahrung ist das aber genau der richtige Wert, weil fast alles, was Nutzer tun, über HTTP läuft.


Phase 2: Download messen (Streaming Reader)

Jetzt wird es interessant. Der Download-Test muss große Datenmengen übertragen und dabei die Geschwindigkeit in Echtzeit anzeigen. Der Trick: Wir nutzen die Streams API (response.body.getReader()), um empfangene Chunks zu zählen, sobald sie ankommen – nicht erst, wenn der gesamte Download fertig ist.

const DOWN_SIZES = [5000000, 10000000, 10000000]; // ~25 MB total

async function measureDownload(onProgress) {
  let totalBytes = 0;
  const tStart = performance.now();

  for (let i = 0; i < DOWN_SIZES.length; i++) {
    const size = DOWN_SIZES[i];
    const url = CF_DOWN + size + '&tid=' + Math.random();
    const resp = await fetch(url, { cache: 'no-store' });
    if (!resp.ok) throw new Error('Download HTTP ' + resp.status);

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

      // Live-Geschwindigkeit berechnen
      const elapsed = (performance.now() - tStart) / 1000;
      const currentMbps = (totalBytes * 8) / (elapsed * 1_000_000);
      onProgress(currentMbps, (i + 1) / DOWN_SIZES.length);
    }
  }

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

Enter fullscreen mode Exit fullscreen mode

Wichtigste Designentscheidungen:

Warum drei Downloads statt einem großen?

Ein einzelner 25-MB-Download misst die Durchschnittsgeschwindigkeit über die gesamte Verbindung – inklusive des TCP-Slow-Start, bei dem die Geschwindigkeit am Anfang noch ramped up. Mit drei aufeinanderfolgenden Downloads (5 MB, 10 MB, 10 MB) ist die Verbindung beim zweiten und dritten Download bereits „warm", und wir bekommen realistischere Werte für die anhaltende Geschwindigkeit.

Warum totalBytes * 8 / elapsed?

Die Magie steckt im Faktor 8: 1 Byte = 8 Bits. Provider verkaufen in MBit/s (Megabit), nicht in MByte/s. Wer hier den Faktor vergisst, misst achtmal zu wenig.

Warum der Streaming-Reader statt await resp.text()?

Würden wir auf das gesamte response.text() warten, hätten wir während des Downloads keine Chance, die Live-Geschwindigkeit anzuzeigen. Mit getReader() bekommen wir jeden eingehenden Chunk sofort und können die UI aktualisieren. Das ist der Unterschied zwischen einer toten Progress-Bar und einer, die „atmet".


Phase 3: Upload messen (XHR statt Fetch)

Der Upload ist etwas kniffliger, weil die fetch-API keine Upload-Progress-Events hat. Wir wollen aber die Geschwindigkeit während des Uploads live sehen, nicht erst am Ende. Lösung: XMLHttpRequest mit dem upload.onprogress-Event.

const UP_SIZE = 8_000_000; // 8 MB

async function measureUpload(onProgress) {
  // Pseudo-random payload gegen Kompression
  const payload = new Uint8Array(UP_SIZE);
  for (let i = 0; i < UP_SIZE; i += 4096) {
    payload[i] = Math.floor(Math.random() * 256);
  }

  const tStart = performance.now();
  return await new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', CF_UP + '?tid=' + Math.random(), true);
    xhr.setRequestHeader('Content-Type', 'application/octet-stream');

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        const elapsed = (performance.now() - tStart) / 1000;
        const mbps = (e.loaded * 8) / (elapsed * 1_000_000);
        onProgress(mbps, e.loaded / e.total);
      }
    };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        const elapsed = (performance.now() - tStart) / 1000;
        resolve((UP_SIZE * 8) / (elapsed * 1_000_000));
      } else reject(new Error('Upload HTTP ' + xhr.status));
    };
    xhr.onerror = () => reject(new Error('Upload fehlgeschlagen'));
    xhr.send(payload);
  });
}

Enter fullscreen mode Exit fullscreen mode

Warum pseudo-random Daten?

Ein subtiler, aber wichtiger Punkt: Wenn wir ein Array aus Nullen hochladen, kann eine Zwischenschicht (Proxy, ISP-Kompression, HTTP/2) die Daten komprimieren. Dann misst der Speedtest die Kompressionsgeschwindigkeit, nicht die echte Upload-Bandbreite. Indem wir alle 4 KB einen zufälligen Wert einfügen, macht das die Daten praktisch inkompressibel.

(Technisch korrekter wäre kryptografisches Rauschen, aber für unseren Zweck reicht das – wir wollen Kompression verhindern, nicht Kryptographie brechen.)

Fallback für den Upload

Manchmal blockieren bestimmte Browser-Konfigurationen oder CORS-Policies den XHR-Upload. Deshalb gibt es einen Fallback auf fetch ohne Progress-Anzeige:

async function measureUploadFallback() {
  const payload = new Uint8Array(UP_SIZE);
  for (let i = 0; i < UP_SIZE; i += 4096) payload[i] = Math.floor(Math.random() * 256);
  const blob = new Blob([payload], { type: 'application/octet-stream' });

  const tStart = performance.now();
  const resp = await fetch(CF_UP + '?tid=' + Math.random(), {
    method: 'POST', body: blob, cache: 'no-store',
    headers: { 'Content-Type': 'application/octet-stream' }
  });
  if (!resp.ok) throw new Error('Upload HTTP ' + resp.status);
  const elapsed = (performance.now() - tStart) / 1000;
  return (UP_SIZE * 8) / (elapsed * 1_000_000);
}

Enter fullscreen mode Exit fullscreen mode


Die UI: Eine analoge Tacho-Nadel in SVG

Technisch am lustigsten war die Visualisierung. Statt einer simplen Progress-Bar wollte ich einen Tacho, dessen Nadel sich flüssig bewegt. Lösung: SVG mit stroke-dasharray und requestAnimationFrame-Animation.

const ARC_LEN = Math.PI * 130; // Halbkreis mit r=130

function setGauge(mbits, color) {
  const pct = Math.min(mbits / MAX_GAUGE, 1);
  gaugeArc.setAttribute('stroke-dashoffset', ARC_LEN * (1 - pct));
  if (color) gaugeArc.style.stroke = color;
  gaugeNum.textContent = mbits >= 100
    ? Math.round(mbits)
    : (mbits >= 10 ? mbits.toFixed(0) : mbits.toFixed(1));
}

function animateGauge(target, duration, color) {
  if (animFrame) cancelAnimationFrame(animFrame);
  const start = parseFloat(gaugeNum.textContent) || 0;
  const t0 = performance.now();
  function tick(now) {
    const t = Math.min((now - t0) / duration, 1);
    const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
    setGauge(start + (target - start) * eased, color);
    if (t < 1) animFrame = requestAnimationFrame(tick);
  }
  animFrame = requestAnimationFrame(tick);
}

Enter fullscreen mode Exit fullscreen mode

Der Trick: Der Halbkreis-Bogen hat eine feste Länge (ARC_LEN ≈ 408). Wir setzen stroke-dasharray auf genau diese Länge und verschieben dann den stroke-dashoffset zwischen ARC_LEN (nichts sichtbar) und 0 (voll sichtbar). Das ist im Grunde eine analoge Anzeige, die wir mit einer Zahl steuern.

Die easeOutCubic-Funktion (1 - (1-t)³) sorgt dafür, dass die Nadel schnell anzieht und sanft am Zielwert abbremsst – wie ein echter Tacho.


Das Urteil: Tarif vs. gemessener Speed

Der Clou des ganzen Tools ist nicht die Messung selbst – sondern die Bewertung. Nutzer geben ihren gebuchten Tarif ein (z. B. 100 MBit/s), und der Speedtest vergleicht:

function updateVerdict(down) {
  const pct = (down / currentTariff) * 100;

  if (pct >= 90) {
    // Top: Du bekommst, was du zahlst
  } else if (pct >= 60) {
    // Mittel: WLAN-Verlust wahrscheinlich
  } else {
    // Schlecht: Tarif oder Leitung passt nicht
  }
}

Enter fullscreen mode Exit fullscreen mode

Warum ist das wichtig? Weil die meisten Menschen nicht wissen, ob ihr Internet gut oder schlecht ist. Sie haben „irgendeinen Tarif" und fühlen sich „irgendwie langsam". Mit dieser Bewertung wird aus einer abstrakten Zahl („73 MBit/s") eine konkrete Aussage: „Du erreichst 73 % deines 100-MBit/s-Tarifs – per LAN-Kabel sollte das mehr sein."

Die Rechtslage in Deutschland hilft hier: Seit 2022 haben Kunden einen gesetzlichen Anspruch darauf, dass der vertraglich zugesicherte Speed zumindest in den meisten Fällen erreicht wird. Wer dauerhaft zu wenig bekommt, kann kündigen oder den Anbieter wechseln.


Was ich gelernt habe

Ein paar Erkenntnisse aus dem Bau:

  1. Vanilla JS reicht für 95 % der Use Cases. Für einen Speedtest brauchst du kein Framework. Die Dependencies hätten nur Bundle-Size und Angriffsfläche vergrößert.
  2. performance.now() ist präziser als Date.now(). Ersteres liefert Mikrosekunden-Auflösung und ist monoton (springt nicht bei NTP-Updates). Für Timing-Messungen immer performance.now().
  3. Streams sind unterutilized. Die ReadableStream-API via response.body.getReader() ist mächtig und wird selten genutzt. Jedes Mal, wenn du große Datenmengen verarbeiten willst und dabei live aktualisieren musst, ist das der Weg.
  4. XHR ist nicht tot. Für Upload-Progress gibt es bis heute keine fetch-Alternative. Manchmal ist das alte Werkzeug das richtige.
  5. Cloudflare ist großzügig. Dass diese Endpunkte öffentlich und ohne Authentifizierung nutzbar sind, ist ein enormes Geschenk an die Web-Community. Ein Danke an das Cloudflare-Team.

Fazit

Der komplette Speedtest – inklusive UI, Tacho-Animation, Ping/Download/Upload-Messung, Tarif-Bewertung und Fehlerbehandlung – ist unter 50 KB groß und in einer einzigen HTML-Datei enthalten. Keine Build-Tools, keine Abhängigkeiten, kein Framework.

Wenn du das Tool ausprobieren willst: dsl.nevik.de/speedtest

Den Quellcode kannst du dir direkt im Browser ansehen (View Source – es ist ja alles inline). Wenn du Fragen zur Implementierung hast oder einen Bug findest, schreib gerne einen Kommentar.


Hast du schon mal einen eigenen Speedtest gebaut? Welche Endpunkte/APIs hast du genutzt? Ich bin gespannt auf eure Ansätze in den Kommentaren! 👇

Tags: #javascript #webdev #vanillajs #tutorial #performance