Traditional network measurement tools assume they can run wherever they want: ping sends ICMP echo requests, mtr watches every hop, packet captures see everything. A browser-based speed test has none of that. No raw sockets, no ICMP, no privileged access — just HTTP(S), JavaScript, and whatever the sandbox allows.

That constraint is a feature. It means the test runs everywhere, installs nothing, and can’t quietly do things your OS wouldn’t approve of. But it makes measurement genuinely hard, and every workaround is a methodological choice that should be documented. This is that documentation: exactly how SpeedPulse measures jitter, grades bufferbloat, and probes 8 independent CDN edges — plus the places where our numbers are imperfect, because pretending otherwise would be worse.

The pipeline at a glance

SpeedPulse measurement pipeline: idle ping phase, download with concurrent pings, upload with concurrent pings, then grading and verdicts Four connected stages: eight idle pings produce median latency and jitter; parallel download streams run alongside a ping train to measure loaded latency; the same happens during upload; finally grades and activity verdicts are computed. MEASUREMENT PIPELINE ONE TEST · ~25 S · ALL IN-BROWSER 01 · IDLE PHASE 8 timed requests median → idle ping mean |Δ consecutive| → jitter 02 · DOWNLOAD + PINGS parallel streams saturate the link ping train runs concurrently 03 · UPLOAD + PINGS parallel uploads saturate the link loaded Δ per phase → down & up bloat 04 · SCORE worst Δ → A+…F 4 activity verdicts CSV + share card history (local only) TIMELINE idle ~3 s download + ping train ~10 s upload + ping train ~8 s score KEY IDEA: ping samples are collected BEFORE, DURING and AFTER each saturation phase — the loaded-minus-idle delta per phase is the bufferbloat measurement, and the worse of the two deltas sets the final A+ to F grade. Upload saturation is usually where connections fail — skinny upstreams bloat first.
One test, four phases. The ping train never stops for long: idle samples give the baseline, and concurrent samples during each saturation phase give the loaded latencies that feed the grade.

Measuring RTT without ICMP

ping uses ICMP. Browsers can’t send ICMP. What a browser can do is issue an HTTP request and time it:

const start = performance.now();
await fetch(url, { mode: 'no-cors', cache: 'no-store' });
const rtt = performance.now() - start;

Two details matter here.

no-cors is the unlock. A normal fetch to a third-party CDN fails unless that CDN sends CORS headers — which most don’t. But with mode: 'no-cors', the browser performs the full network round trip (DNS, TCP, TLS, HTTP) and hands back an opaque response: no status code, no body, just “it completed.” For latency measurement, “it completed, and it took 14 ms” is all we need. This lets us time any CDN edge on the planet, not just the ones that opted into CORS.

We probe tiny resources. A favicon or a zero-byte file keeps the transfer time negligible relative to the round trip, so the number reflects network latency, not payload size. (One CDN’s favicon used to 404 and return a ~230 KB error page — which silently inflated every “RTT” to it. We caught it because its numbers looked absurd next to the others, and switched to a tiny asset served from the same edge. That bug is exactly why cross-checking multiple CDNs matters.)

Loss counting is conservative. With opaque responses we can’t read status codes, so a 404 still counts as a completed round trip. Only genuine network failures — DNS errors, connection failures, aborts — count toward the loss rate. That’s deliberate: it under-reports HTTP-level errors rather than blaming the network for things the network didn’t do.

Jitter: eight samples, one honest number

Jitter is computed as the mean absolute difference between consecutive RTT samples:

jitter = mean( |rtt[i+1] − rtt[i]| )   for i = 1..n−1

This is the standard RFC 3550-style consecutive-difference formulation (rather than deviation-from-mean, which understates bursty behavior). Eight samples keep the test fast while giving a usable estimate: enough to distinguish a rock-steady 3 ms from a chaotic 40 ms, which is the decision that matters.

For the idle ping itself we report the median, not the mean. One DNS lookup hiccup or GC pause in the browser can spike a single sample; medians shrug that off.

Known bias, stated plainly: an HTTP round trip includes connection overhead a raw ICMP ping doesn’t — TLS handshakes on the first sample, TCP-level details throughout. So our “ping” reads a few milliseconds higher than a terminal ping to the same host. We consider that acceptable: (a) real application traffic also pays those costs — arguably HTTP RTT is the more honest proxy for user experience — and (b) for bufferbloat we only care about the delta between loaded and idle samples over the same connection, where the overhead cancels out.

Bufferbloat: the delta that matters

During both the download and upload phases, the ping train keeps running concurrently with the saturating transfer streams. For each phase:

loaded_latency = median(rtt samples during saturation)
bufferbloat_phase = loaded_latency − idle_latency

We report the download-phase and upload-phase loaded latencies separately (the CSV export includes both), and grade on the worse of the two deltas, because your experience is governed by the worst moment your connection produces:

GradeExtra latency under load
A+< 5 ms
A< 30 ms
B< 60 ms
C< 200 ms
D< 400 ms
F≥ 400 ms

These thresholds match the scale popularized by Waveform and the bufferbloat.org community, so grades are comparable across tools that adopt it. We’d rather share a standard than invent a proprietary scale that flatters our own algorithm.

The multi-CDN panel: measuring whether the test itself is gamed

After every speed test, we probe 8 independent CDN edges — Cloudflare, jsDelivr, Bunny, Fastly, UNPKG, GitHub, Google and AWS — with 5 samples each, and chart median RTT, jitter and loss per edge:

Median latency to eight CDN edges, with Cloudflare suspiciously fast at 12 ms and a dashed median line at 20.5 ms Horizontal bar chart of median RTT per CDN: Cloudflare 12, jsDelivr 16, Bunny 24, Fastly 19, UNPKG 15, GitHub 22, Google 34, AWS 29 milliseconds. A dashed line marks the 20.5 ms median. Cloudflare is highlighted as the speed-test server. MULTI-NODE LATENCY — 8 CDN EDGES × 5 SAMPLES MEDIAN RTT · MS Cloudflare ★ jsDelivr Bunny.net Fastly UNPKG GitHub Google AWS MEDIAN 20.5 12 16 24 19 15 22 34 29 ← test server · 40% faster than the pack — prioritized? 01020 3040 IF ONE CDN SITS FAR BELOW THE PACK, THE NETWORK MAY BE TREATING ITS TRAFFIC DIFFERENTLY — ILLUSTRATIVE DATA
Eight second opinions. The panel's job is to make the *shape* of the chart meaningful: all bars clustered together means an honest test path; one bar dramatically faster than the pack — especially the test server — is the classic fingerprint of speed-test traffic being treated specially somewhere along the route.

The heuristic we suggest (and show hints for): compare the test-server bar against the median of the pack. A modest gap is geography — CDNs have edges everywhere. A dramatic, persistent gap in the test server’s favor is worth a raised eyebrow, because it means the number you just got is better than your average internet experience, possibly by design.

What we deliberately don’t do

  • No accounts, no server-side history. Your results history lives in your browser’s localStorage. The CSV export contains exactly eight columns — timestamp_iso, download_mbps, upload_mbps, ping_ms, jitter_ms, loaded_latency_down_ms, loaded_latency_up_ms, bufferbloat_ms — and leaves your machine only if you attach it to an email yourself.
  • No proprietary scoring black box. The grade thresholds are published above; the verdict logic is plain threshold rules (e.g., gaming loses points above 20 ms idle ping, 5 ms jitter, or 60 ms bufferbloat), and every verdict string names the metric that limited it.
  • No single flattering path. Every result ships with the 8-CDN cross-check, by design.

Limitations — the honest list

  1. HTTP RTT ≠ ICMP RTT. A few ms of protocol overhead rides along (see above). Deltas remain valid; absolutes are approximate.
  2. Browser timer and scheduling noise. performance.now() is high-resolution, but the JS event loop can delay issuing a fetch. Medians over multiple samples absorb most of this; sub-millisecond precision is not claimed.
  3. First-sample warmup. DNS + TLS handshakes land on the first probe to a fresh origin. Warm connections (repeated tests, or a CDN your browser already uses) measure lower. We take multiple samples and use medians to blunt this.
  4. Parallel-stream speed variance. Browser connection limits and CPU contention add run-to-run variance to throughput numbers — true of every browser speed test, rarely admitted.
  5. One vantage point per run. We test from your browser to our test server, cross-checked against 8 CDNs. We do not — cannot — traceroute, inspect per-hop queues, or see your ISP’s internal congestion. mtr and Wireshark still have jobs.

If you spot a flaw we haven’t listed, that’s exactly what the discussion is for.

Discussion

  • Is HTTP-RTT a better realism proxy than ICMP for user experience, or a compromise we tolerate? We suspect the former — web apps pay HTTP costs too — but the case isn’t closed.
  • The no-cors opaque-response trick for timing third-party CDNs is hardly documented anywhere. What other measurement primitives is the web platform hiding in plain sight?
  • What would you add to the CSV schema? Net type (Wi-Fi/Ethernet/4G) is on our shortlist — argue for yours.

Want to see the methodology applied to your own line? Run the test — the multi-node panel fires automatically when it finishes, and the full feature announcement explains what each new number means.

Related articles