How Riptide builds a terminal speed test with Go and Bubble Tea
Running a speed test in a browser feels wrong when you’re already in a terminal. Riptide fixes that. It’s a Go CLI that measures download speed, upload speed, latency, and live adapter throughput, then renders everything in a centered card UI with sparklines and smooth animation. It works on both Linux and Windows.
But the part I find worth digging into isn’t the speed test itself — it’s how Riptide is put together. The Bubble Tea integration, the build tag strategy for cross-platform network monitoring, and the clean package boundaries all make it a useful codebase to study.
Project structure and the Elm architecture
Riptide follows a standard Go layout. The entry point is cmd/riptide/main.go, with most of the application under internal/. The internal/ui/ package owns presentation. internal/engine/ owns the speed test and live monitor. internal/db/db.go handles local storage for test history.
The UI is built with Bubble Tea, which brings the Elm Architecture to Go. You define a model, an Update function that handles messages, and a View function that returns a string. Riptide has a top-level Bubble Tea model in internal/ui/app.go, then smaller models for the menu, one-shot speed test, live monitor, settings, and save prompt.
The view layer is spread across multiple files: internal/ui/card.go for the centered card layout, internal/ui/sparkline.go for bandwidth sparklines, internal/ui/monitor.go for the real-time monitor, internal/ui/history.go for past results, internal/ui/menu.go for the main menu, and internal/ui/settings.go for preferences. Each file maps to a screen or component.
I like this decomposition. A lot of Bubble Tea projects end up with one file that handles every keypress, every screen, and every render branch. Riptide avoids that by keeping each view’s rendering logic in its own file. The top-level App routes messages to the active sub-model, so the speed test and live monitor can evolve separately.
Platform-specific network monitoring with build tags
This is the most Go-flavored part of Riptide. Real-time bandwidth monitoring works differently on Linux and Windows, and Riptide handles this with two files: internal/engine/monitor_linux.go and internal/engine/monitor_windows.go. Go’s build constraints let the compiler pick the right one based on the target OS. No if runtime.GOOS == "linux" sprinkled through the code.
On Linux, the monitor reads active, non-loopback interfaces from /sys/class/net, then uses statistics/rx_bytes and statistics/tx_bytes to compute byte deltas. On Windows, it uses golang.org/x/sys/windows, GetAdaptersAddresses, and GetIfEntry to read active adapter counters. Both implementations sum active adapters so the UI shows total machine throughput rather than one hard-coded interface.
The shared counter type and delta logic live in internal/engine/monitor_counters.go. This is a useful Go pattern: keep the common semantics in a platform-neutral file, then provide platform-specific implementations behind build tags. Riptide even accounts for counter resets and Windows-style 32-bit wraparound so a restarted adapter does not create a fake traffic spike.
Here’s what the pattern looks like in general terms:
// monitor_counters.go - shared code, no build tag
package engine
type counters struct {
rx uint64
tx uint64
}
Each platform file uses a build constraint at the top (//go:build linux or //go:build windows) and provides RunMonitor for that operating system. Platform logic stays isolated. Want to add macOS support? Add a monitor_darwin.go, read the right counters, and preserve the same progress/sample behavior.
If you’ve worked with Go’s context package before, you already know why cancellation matters here. A real-time monitor runs in a loop, and you need a clean way to kill it when the user switches views or exits.
Speed testing in Go
The speed test logic lives in internal/engine/speed.go. Riptide uses fast.com’s public speed-test configuration endpoint to discover target URLs, then runs separate download, upload, and latency phases. In Go terms, that means net/http, contexts, goroutines, byte counters, and a sampler loop.
A typical approach:
func measureDownload(ctx context.Context, url string) (float64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, err
}
start := time.Now()
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
n, err := io.Copy(io.Discard, resp.Body)
if err != nil {
return 0, err
}
elapsed := time.Since(start).Seconds()
mbps := (float64(n) * 8) / (elapsed * 1_000_000)
return mbps, nil
}
Riptide’s implementation is a little more involved than that small example. Download workers repeatedly GET discovered target URLs and count bytes as chunks arrive. Upload workers repeatedly POST a 256 KiB random buffer. A sampler snapshots the shared atomic counter every 100 milliseconds, emits live samples to the UI, and records peak throughput.
The tricky part in a Bubble Tea app: network operations block. You do not want the Update loop waiting on HTTP transfers. Riptide launches the engine through a small bridge that runs work in the background, converts phase changes and samples into Bubble Tea messages, and keeps returning commands such as listenCmd and ticks so the card can animate while the test is running.
Theming and the sparkline renderer
internal/theme/theme.go defines colors and styles using Lip Gloss, Charm’s styling library. All visual constants in one place.
The sparkline implementation in internal/ui/sparkline.go is one of those things that’s more satisfying than it has any right to be. Sparklines use Unicode block characters (▁▂▃▄▅▆▇█) to draw a miniature chart of bandwidth over time. You normalize your data points to a 0-7 range and pick the corresponding block character. It’s a small technique, but it makes CLI applications feel genuinely polished.
Local history with SQLite
internal/db/db.go stores test results locally, which feeds the history view in internal/ui/history.go. The database layer follows a familiar Go shape: a struct wrapping a *sql.DB connection, with methods for inserting and querying results. Keeping database access in its own package under internal/ means the UI and engine packages don’t touch SQL at all. They don’t even know it exists.
Cross-platform releases
The .github/workflows/release.yml file handles automated builds when a v* tag is pushed. It calls make dist, and the Makefile cross-compiles four archives: Linux and Windows, each for amd64 and arm64. The build tags in the engine package ensure the right monitor implementation gets compiled for each target.
What’s worth stealing
Riptide pulls together several Go patterns that work well on their own but are even better in combination:
Build tags for platform code beat runtime checks every time. Split OS-specific logic into separate files with a shared interface in a neutral file.
The Bubble Tea command pattern keeps UIs responsive. Run blocking operations as tea.Cmd functions that return messages. If you’re building any terminal application, you’ll reach for this constantly.
Package boundaries that mean something. internal/engine, internal/ui, internal/db, and internal/theme each own one concern. The engine doesn’t know about rendering. The UI doesn’t know about SQL. This sounds obvious written down, but plenty of projects blur these lines within the first week.
Measuring network bandwidth in Go comes down to counting bytes, tracking time, and doing arithmetic. Riptide uses atomic counters for transfer phases, a short sampler interval for live UI updates, and OS adapter counters for passive monitoring.
If you’re building Go CLI tools that need to work across platforms, Riptide’s source is worth reading. The platform-specific monitor files and the Bubble Tea integration are the highlights. Grab the repository and poke around.