
What a Speed Test Is Actually Measuring
Building a small Go backend for measuring network speed taught me that the hard part isn't measuring bytes, it's getting buffering, compression, caching, and timeouts out of the way first.
What a Speed Test Is Actually Measuring
I recently wrote a small Go backend whose entire job is to answer one question: how fast is this connection? Ping, download, upload, nothing else. It sounds like the kind of thing you'd knock out in an afternoon, four HTTP handlers, how hard can it be. It took a lot longer than that, because almost none of the actual work is about moving bytes. It's about making sure nothing other than the network gets to influence the number you print on screen.
Latency is measuring the absence of work
The /ping handler is, by design, almost nothing:
func PingHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
No body, no JSON encoding, no cache headers to compute, nothing. That emptiness is the entire point. The moment the handler does any real work, parses a query string, touches a struct, writes a response body, you've added server-side processing time into a number that's supposed to represent only the round trip. A latency figure is only honest if the server's half of the job is close enough to zero that it disappears into noise next to the network itself.
The other thing a single ping can't give you is jitter, how much that round trip varies from one request to the next. That only shows up once you fire several of these back to back and look at the spread, which is a client-side concern entirely, the server's only obligation is to stay out of the way every single time, consistently.
Download: the buffer is lying to you by default
The download handler streams random bytes for a fixed duration. The first version I wrote filled a large buffer and wrote it in one call, then let the loop repeat. The numbers it produced on a fast local network were absurd, because a single Write() returning doesn't mean the bytes left the machine. The kernel's socket send buffer will happily swallow a few hundred kilobytes before anything hits the wire, so timing "how long until Write returns" on a big buffer mostly measures how long it takes to fill that buffer, not how long it takes to actually deliver data.
The fix was to shrink the buffer and flush aggressively instead:
buffer := make([]byte, 32*1024)
rand.Read(buffer)
for {
select {
case <-timeout:
return
default:
w.Write(buffer)
if canFlush {
flusher.Flush()
}
}
}
Small, frequent chunks, each one flushed through http.Flusher, turn the response into a steady trickle spread across the whole duration instead of one buffered blob. That's also why the payload is random bytes from crypto/rand rather than zeros or repeated text: anything compressible is fair game for a proxy, CDN, or even the transport layer to compress in flight, and a compressed payload measures the compressor's ratio, not the link's bandwidth.
Upload is the easy half, which is itself informative
Compared to all that, the upload handler is almost nothing:
func UploadHandler(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusOK)
}
Read everything, throw it away, say OK. There's no buffering trick to get right here because the server isn't the one producing data under time pressure, it's just draining a stream as fast as the kernel hands it over. The asymmetry is the interesting part: all the real difficulty of measuring upload speed lives on the client, which has to time how long it takes to push N bytes through. The server's job reduces to "don't be the bottleneck," which io.Discard guarantees almost by construction.
A timeout you didn't know you set
This is the one that actually caught me. The HTTP server has a blanket write timeout:
server := &http.Server{
WriteTimeout: 30 * time.Second,
// ...
}
while the download handler accepts a client-supplied duration query parameter with no upper bound. Request a 10-second test and everything's fine. Request a 60-second test, and the connection gets killed by the server's own timeout at the 30-second mark, mid-stream, with no distinguishing error, it just stops. For a while that looked exactly like a flaky network or a dropped connection on the client side. It wasn't; it was the server timing itself out. A blanket timeout and a client-parameterized duration were never reconciled against each other, and nothing in either code path would have told you that at a glance. The lesson generalizes past this one handler: any value you let a caller parameterize has to be checked against every timeout, limit, or deadline already sitting upstream of it, a framework default doesn't know your query parameter exists, and it will happily enforce itself regardless.
The cache is the middleman nobody asked for
The last piece is in the middleware, which sets, unconditionally:
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
on every response. Skip this and the failure mode is quiet: a browser, a corporate proxy, or a CDN edge serves a cached copy of a /download response instead of actually streaming fresh bytes from origin, and now you're benchmarking the cache, not the path to the server. The same middleware deliberately exempts /ping and /health from the API-key check that every other route requires, not because they're less sensitive, but because gating them would force the browser into a CORS preflight, an extra OPTIONS round trip, right before the one request that's supposed to be measuring round trips. Correctness here isn't just "does the handler return the right bytes," it's making sure the path leading up to the handler doesn't quietly contaminate the measurement before a single byte of the response matters.
None of this is really about networking. It's about noticing all the layers, the kernel's send buffer, an HTTP cache, a blanket server timeout, that are normally invisible conveniences, and realizing that for this one kind of service, each of them is a source of bias you have to actively suppress. The number you actually want, a cold, uncached, uncompressed, unbuffered measurement of how fast bytes move, only shows up once you've gotten everything else out of its way.
One server only measures one path
Even after all that, a single server sitting in one data center only tells you how fast that path is, your connection to that one machine, wherever it happens to sit on the map. That's a fine number, but it isn't "your internet speed," because the public internet isn't one uniform pipe, it's a chain of hops through your ISP, through whatever transit providers it peers with, and finally to wherever the server lives. Test against a server on the other side of the planet and you're mostly measuring transcontinental routing, not your last mile.
This is why services like Speedtest actually run a program where ISPs and hosting providers host servers all over the place, and the client picks the geographically and topologically nearest one before running the test at all. The server selection step isn't a convenience feature bolted on top, it's load-bearing: without it, "speed test" quietly becomes "speed to one arbitrary point on earth," and the result stops meaning what people think it means. A real deployment of something like this isn't one engine, it's the same engine replicated close to users in many places, with a selection step in front of it that picks the nearest one before the actual measurement ever starts.