Real-time Cloudflare speedtest monitoring dashboard powered by R2 object storage.
SpeedWatch reads JSON speedtest result files from an R2 bucket and displays them through a REST API consumed by an interactive Astro UI.
Cloudflare Worker (speedwatch)
│
├─► /api/summary → Aggregated KPIs, incidents, timeline, endpoint breakdown
├─► /api/results → Paginated raw records
├─► /api/config → Runtime threshold configuration
├─► /llms.txt → AI discovery
│
▼
R2 Bucket (JSON files, one per speedtest run)
- Ingestion: Speedtest agents upload JSON results to an R2 bucket
- Parsing: Worker reads R2 files, parses metrics, converts bytes/sec to Mbps
- Aggregation: Summarises data by time windows, calculates percentiles (p50/p95/p99), groups by endpoint
- Cron Job: Runs every minute to precompute aggregated summaries for 1h, 6h, 24h, 7d, and All time windows, stored as
_cache/summary-{hours}.jsonin R2 - Prebuilt Summaries: Ultra-fast response (~10ms) when prebuilt data exists; contains only aggregated KPIs, timeline, and incidents (no raw records)
- Metadata-First Parsing: R2 list includes custom metadata, allowing record parsing without body fetch when available; dramatically reduces bandwidth
- Anomaly Detection: Dual-gate model — a value is only flagged if it exceeds both a relative deviation threshold and an absolute floor (see Anomaly Detection below)
- Incident Detection: Consecutive CRIT-level records are merged into a single incident card; warn-level runs do not surface as incidents
- Dashboard: Renders KPIs, charts, incidents, endpoint breakdown, and results table
SpeedWatch is designed to work with JSON speedtest results and is particularly well-suited for data from:
- netzbremse-measurement — A comprehensive network speed measurement tool that produces compatible JSON results. Simply upload the JSON output files to your R2 bucket to visualise them in the dashboard.
GET /api/summary?hours={24}— Aggregated KPIs, percentiles, per-endpoint breakdown, hourly timeline, incidents. Serves from R2 prebuilt summaries when available (~10ms) or live aggregation (~1000ms)GET /api/results?limit={100}&cursor={cursor}&endpoint={name}&from={iso}— Paginated raw test records. Uses metadata-first R2 parsing for optimal performanceGET /api/config— Runtime configuration including relative thresholds and absolute floorsGET /llms.txt— AI agent discoverability documentGET /api/results?limit={100}&cursor={cursor}&endpoint={name}&from={iso}— Paginated raw test recordsGET /api/config— Runtime configuration including relative thresholds and absolute floorsGET /llms.txt— AI agent discoverability document
- KPI Cards — Average download, upload, latency, jitter with sparklines and p95
- Incidents Panel — Critical-only degradation incidents with affected metrics highlighted
- Throughput Chart — Download (orange) and upload (blue) area charts
- Latency Chart — Latency line chart
- Jitter Chart — Jitter line chart
- Endpoint Breakdown — Per-endpoint cards with progress bars and metric tiles
- Results Table — Paginated table with anomaly highlighting
- Auto-refresh — 60-second data refresh with smart cache invalidation; clears in-memory cache and re-fetches active view, then silently preloads others
- Time Range Pills — Default 1h view (fastest load); instant switching via in-memory cache; other windows (6h, 24h, 7d, All) preload in background
- Three-Layer Caching: UI in-memory cache → R2 prebuilt summaries → Cloudflare KV cache for optimal performance
SpeedWatch uses a dual-gate model for flagging values in the Results table and Endpoint Breakdown. A value is only highlighted if it clears both gates simultaneously:
| Gate | Description |
|---|---|
| Relative | The value deviates significantly from the median of the last 100 records |
| Absolute floor | The value also exceeds a minimum threshold that represents objectively degraded performance |
This prevents false positives on excellent connections — e.g. a jitter of 2ms will never be flagged red simply because the session median happens to be 0.5ms.
Defined in src/types.ts and served via /api/config:
| Metric | Warn floor | Crit floor |
|---|---|---|
| Download | 50 Mbps | 25 Mbps |
| Upload | 20 Mbps | 10 Mbps |
| Latency | 20 ms | 50 ms |
| Jitter | 5 ms | 15 ms |
| Variable | Default | Meaning |
|---|---|---|
ANOMALY_WARN_THRESHOLD |
0.70 |
Throughput warn if < 70% of median; latency/jitter warn if > 1/0.70 ≈ 143% of median |
ANOMALY_CRIT_THRESHOLD |
0.50 |
Throughput crit if < 50% of median; latency/jitter crit if > 1/0.50 = 200% of median |
Both thresholds are configurable via environment variables (see Configuration).
Incidents are constructed on the backend from raw records using fixed absolute/ratio thresholds (classifyRecord() in src/types.ts). Only CRIT-level runs surface as incident cards — consecutive warn-level records do not create incidents. The metric tiles inside each incident card highlight exactly the metrics that contributed to the critical classification.
All configured in wrangler.jsonc:
| Variable | Default | Description |
|---|---|---|
R2_PREFIX |
"speedtest-results/" |
Folder path prefix in R2 bucket |
CACHE_TTL_SECONDS |
"60" |
KV response cache duration in seconds |
MAX_RESULTS_PER_PAGE |
"200" |
Maximum records returned per results page |
ANOMALY_WARN_THRESHOLD |
"0.70" |
Relative warn threshold (fraction of median baseline) |
ANOMALY_CRIT_THRESHOLD |
"0.50" |
Relative crit threshold (fraction of median baseline) |
To customise thresholds:
- Go to Cloudflare Dashboard → Workers & Pages
- Select your
speedwatchWorker - Settings → Variables
- Add/update the variable and save to trigger redeployment
| Binding | Type | Value |
|---|---|---|
R2_BUCKET |
R2 Bucket | Your speedtest results bucket |
RATE_LIMITER |
Rate Limit | 60 requests per 60s |
Supported patterns:
speedtest-{timestamp}.json— Default pattern{timestamp}.json— Without prefix
{
"sessionID": "2683d050-da44-458d-9257-360c143f8af8",
"endpoint": "https://custom-t0.speed.cloudflare.com",
"success": true,
"result": {
"download": 71965572.58, // bytes/sec — converted to Mbps automatically
"upload": 35293340.94, // bytes/sec — converted to Mbps automatically
"latency": 7.50, // ms
"jitter": 1.21, // ms
"downLoadedLatency": 22.80, // ms
"downLoadedJitter": 6.72, // ms
"upLoadedLatency": 51.50, // ms
"upLoadedJitter": 14.26 // ms
}
}- Cloudflare account with Workers enabled
- GitHub account (for Workers Builds integration)
- Existing R2 bucket with speedtest JSON files
Workers Builds automatically deploys on push to main.
- Go to Cloudflare Dashboard
- Workers & Pages → Create Application
- Connect to Git → Authorise GitHub → Select
speedwatchrepo - Click Begin setup
- Build settings (pre-configured):
- Build command:
npm install && npm run build - Deploy command:
npx wrangler deploy
- Build command:
- Click Save and Deploy
npm install
npm run devDev server runs on http://localhost:8787/
| Issue | Fix |
|---|---|
| Dashboard empty | Upload speedtest JSON files to R2 bucket |
R2_BUCKET binding error |
Verify bucket name in wrangler.jsonc matches actual bucket |
| API rate limited | Wait 60s or adjust RATE_LIMITER in wrangler.jsonc |
| Build fails | Run npm run typecheck to check for type errors |
| Healthy values flagged as warn/crit | The absolute floors may need tuning for your connection; update THRESHOLDS in src/types.ts |
MIT




