I wanted to know a simple thing: how long does this blog take to load?

It turns out that’s a harder question to answer than I expected, mostly because there isn’t really a server to ask. This site is built with Hugo and deployed as a static site on Render, which serves it through Cloudflare. I don’t run anything. There’s no process I can add metrics to, no access log I can read, no /metrics endpoint. If I want to know how fast it is, I have to measure it from the outside.

So I wrote a small Go program called probe. This post is about what it does, and why I wrote one instead of using one of the many tools that already exist.

what it does Link to heading

probe sends the same request over and over and times each phase of it separately:

$ ./probe -n 100 https://bala-krishnan.com/
probing https://bala-krishnan.com/  ×100  (fresh connection per request, 200ms apart)

     phase     min      avg      p50      p90      p99      max
       dns   1.1ms    2.4ms    2.0ms    3.6ms    8.8ms   17.9ms
   connect  21.9ms   29.2ms   28.3ms   33.0ms   51.5ms   53.9ms
       tls  30.6ms   45.0ms   42.2ms   53.4ms   81.4ms   95.4ms
      wait  27.3ms   43.9ms   42.6ms   55.1ms   66.1ms   66.7ms
  transfer   0.1ms    0.5ms    0.2ms    0.5ms    3.5ms   13.0ms
     total  90.0ms  121.3ms  118.9ms  142.7ms  175.6ms  215.4ms

The phases are:

  • dns: turning bala-krishnan.com into an IP address
  • connect: the TCP handshake
  • tls: the TLS handshake, so we can talk HTTPS
  • wait: from sending the request to getting the first byte back. This is the closest thing to “how long the server took”, plus one network round trip.
  • transfer: downloading the rest of the page

It also records Cloudflare’s cf-cache-status header on every response, so I can see which requests were served from Cloudflare’s cache and which ones had to go all the way back to Render. And it can save every request to a CSV file, so I can make charts from it later.

There are two modes. By default every request opens a brand new connection, which is what a first-time visitor experiences. With -reuse, it keeps the connection open between requests, which is closer to someone clicking around the site.

how it works Link to heading

The whole thing is about 400 lines of Go, and the interesting part is maybe 30 of them.

Go’s standard library has a package called net/http/httptrace. You give it a struct full of functions, and Go’s HTTP client calls them at specific moments inside a request: DNS lookup started, DNS lookup finished, connection established, TLS handshake done, first response byte received. Each of my functions just writes down the current time:

trace := &httptrace.ClientTrace{
	DNSStart:             func(httptrace.DNSStartInfo) { dnsStart = time.Now() },
	DNSDone:              func(httptrace.DNSDoneInfo) { dnsDone = time.Now() },
	TLSHandshakeStart:    func() { tlsStart = time.Now() },
	TLSHandshakeDone:     func(tls.ConnectionState, error) { tlsDone = time.Now() },
	GotFirstResponseByte: func() { firstByte = time.Now() },
	// ...
}

Then each phase is just “end minus start”. I didn’t know this package existed before writing this, and I think it’s really nice. It’s the same idea as the hooks in curl -w, but you get to decide what to do with them.

One thing that surprised me: ConnectStart can fire twice for one request. Go tries IPv4 and IPv6 connections at almost the same time and uses whichever one connects first (this is called “happy eyeballs”). So the code has to keep the first start time and the first successful finish, or the connect numbers come out wrong.

why not just use curl? Link to heading

This is where I started. curl can print timings for a single request:

$ curl -s -o /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://bala-krishnan.com/
dns=0.002412 connect=0.031866 tls=0.078144 ttfb=0.124193 total=0.124516

Two things made me move on:

  1. One request doesn’t tell you much. I ran this a few times and got noticeably different numbers each time. I wanted a hundred samples and a summary, which means wrapping curl in a shell loop and then doing statistics in awk. That’s possible, but at that point it’s a program anyway.
  2. curl’s timings are cumulative. time_appconnect isn’t how long TLS took. It’s how long everything up to the end of TLS took. So “ttfb=0.124” doesn’t mean the server took 124ms. You have to subtract each number from the next one to get the phases. I misread this at first, which is a good sign it’s easy to misread.

httpstat is a lovely wrapper that does the subtraction for you and draws the phases. But it’s still one request at a time.

why not a load testing tool? Link to heading

Tools like hey, wrk, vegeta and k6 can send thousands of requests and give you latency percentiles. hey even prints a per-phase breakdown.

But they’re built to answer a different question: what happens when lots of people hit this at once? They send requests concurrently, as fast as they can, and they’re very good at that. I wanted the opposite: one polite request at a time, spaced out, like a series of individual visitors. Pointing a load generator at someone else’s CDN to measure my personal blog felt like the wrong tool.

I also wanted things none of them give me out of the box, like grouping results by Cloudflare’s cache status, and a clean switch between fresh and reused connections.

why not Prometheus blackbox_exporter? Link to heading

If you already run Prometheus, the blackbox_exporter is the standard answer. It probes a URL on a schedule and exports a probe_http_duration_seconds metric with a phase label for resolve, connect, tls, processing and transfer. That’s basically the same breakdown probe gives me, and it runs forever.

But I don’t run Prometheus for this blog, and standing up Prometheus, the exporter and Grafana to measure a static site felt like a lot of infrastructure for one question. I’d use it in a heartbeat at work.

why not Grafana or Datadog synthetic monitoring? Link to heading

Hosted synthetic monitoring is the grown-up version of what probe does. You configure a check, and the vendor runs it on a schedule from many locations around the world.

These are genuinely better than probe in some important ways. They run continuously, they test from places my readers actually are rather than from my desk, and they alert you when something breaks.

For this blog, though, they’re more than I need: an account, configuration, and (beyond the free tiers) a bill, to watch a site with a few dozen visitors. And I wanted to understand what a “DNS time” or a “TLS time” actually is, not just read it off someone else’s dashboard. Building the small version first is how I learn what the big version is doing.

what about RUM? Link to heading

Everything above is synthetic: a robot makes requests and times them. Real User Monitoring (RUM) is the other approach: a small script in your pages measures how long the page took in each real visitor’s browser, and sends the numbers back.

The difference matters. Synthetic monitoring tells you how fast the site is from where the probe is. RUM tells you how fast it is for the people actually reading it, on their phones, on hotel Wi-Fi, on the other side of the world. RUM also sees things a probe never will, like how long the browser takes to render the page.

So why not RUM? A few reasons:

  • It’s a different question. I wanted to understand the network path to my site, phase by phase, under conditions I control. RUM gives you the messy real-world answer, which is more useful for a product but harder to learn from.
  • Traffic. My analytics show a few dozen visitors in the period I looked at. RUM percentiles need a lot of sessions to be meaningful, and I’d be looking at a handful of data points.
  • It adds something to every page. A RUM script runs in every reader’s browser, sends data to a third party, and deserves a privacy note. For a blog, I’d want a good reason for that.

I don’t think synthetic and RUM are competitors. Synthetic is the controlled experiment; RUM is what happens in the wild. A real production service should probably have both.

reading a real run Link to heading

First, where these numbers come from, because it matters a lot: every run in this post is from my laptop in Kerala, in southern India, over home Wi-Fi. My requests go to Cloudflare’s data centre in Chennai (the cf-ray header ends in MAA, Chennai’s airport code), a few hundred kilometres away. Someone reading this in the US would be talking to a completely different Cloudflare location, over a completely different network, and would get different numbers.

Here’s a run of 200 requests, each on a fresh connection:

$ ./probe -n 200 https://bala-krishnan.com/
probing https://bala-krishnan.com/  ×200  (fresh connection per request, 200ms apart)

     phase     min      avg      p50      p90       p99       max
       dns   1.3ms    3.0ms    2.2ms    3.5ms     8.0ms   114.4ms
   connect  21.8ms   68.0ms   29.1ms   49.3ms  1036.1ms  1059.7ms
       tls  31.9ms   58.2ms   42.5ms   66.2ms   270.9ms  1055.5ms
      wait  29.9ms   55.7ms   41.7ms   64.1ms   484.1ms   525.6ms
  transfer   0.1ms    0.5ms    0.3ms    0.6ms     5.8ms     9.1ms
     total  90.9ms  185.9ms  123.1ms  227.5ms  1188.7ms  1542.0ms

total time histogram
     90.9ms │########################################  169  ← p50
    163.5ms │###                                        11  ← avg
    236.0ms │##                                          6
    308.6ms │#                                           1
    381.2ms │                                            0
    453.7ms │#                                           2
    526.3ms │#                                           1
    598.8ms │#                                           1
    671.4ms │                                            0
    743.9ms │#                                           1
    816.5ms │                                            0
    889.0ms │                                            0
    961.6ms │                                            0
   1034.1ms │                                            0
   1106.7ms │##                                          5
   1179.3ms │#                                           2  ← p99
   1251.8ms │                                            0
   1324.4ms │                                            0
   1396.9ms │                                            0
   1469.5ms │#                                           1

by cf-cache-status
   status  count      avg      p50       p99
  EXPIRED      1  782.6ms  782.6ms   782.6ms
      HIT    199  182.9ms  123.1ms  1200.3ms

(p50 means half the requests were faster than this; p99 means 99% were.)

There’s a lot in here, so I’ll go through it a piece at a time.

A typical request takes about 123ms, and the server is a small part of it. Look down the p50 column: DNS 2ms, connect 29ms, TLS 42ms, wait 42ms, transfer under 1ms. The connect time is a good measure of the distance: a TCP handshake takes one round trip, so it’s about 29ms from my desk in Kerala to Chennai and back. Only the wait phase involves the server at all, and even that includes one round trip across the network. The other 70-odd milliseconds are just setting up a connection: finding the address, shaking hands, agreeing on encryption. The page itself downloads in under a millisecond because it’s small.

Most requests are fast. 169 of the 200 requests landed in the first bar of the histogram, under about 164ms. If you only looked at the typical request, you’d say the site is quick and move on.

But there’s a second group, at just over a second. Seven requests sit in the 1.1–1.2 second bars, with nothing between them and the main group except a few stragglers. Slowness that’s random usually smears out across the whole range. A cluster at one specific number usually has one specific cause.

The phases point at the cause: connect. The connect row has a p50 of 29ms and a p99 of 1036ms. And 1036ms is suspiciously close to 1 second plus one normal connect.

That’s what a lost packet during the TCP handshake looks like. To open a connection, my laptop sends a SYN packet and waits for a reply. If the SYN (or the reply) gets lost somewhere, nothing tells the client. It just waits, and the standard starting timeout before trying again is 1 second (RFC 6298). The retry gets through in the usual ~30ms, so the connect takes about 1,030ms. Add the normal TLS and wait time and you get a request of about 1.1 seconds, which is exactly where the cluster is.

Where would packets get lost? My best guess is the first hop: home Wi-Fi drops packets far more often than the wired networks between ISPs and Cloudflare do.

TLS has the same signature. Its max is 1055ms, which is probably the same thing happening to a packet during the TLS handshake instead.

The cache miss is a different kind of slow. One request came back EXPIRED: Cloudflare’s copy of the page had gone stale, so it had to fetch a fresh one from Render before answering. That took 782ms, and the extra time should show up in wait, the phase where the server (here, Cloudflare plus Render) is doing work. The wait row’s max of 525ms is very likely that request.

And the slow cache hits aren’t Cloudflare’s fault. The HIT row has a p99 of 1200ms. Those are requests Cloudflare answered straight from its cache. They were slow because of the network on the way there, not because of anything on the server side.

DNS mostly isn’t a factor. It’s 2ms almost every time, because my machine caches the answer. The one 114ms lookup was probably a cache entry expiring, so the lookup had to go out to a real DNS server.

So the 200 requests show three different behaviours: a fast normal path at about 123ms, a cache miss that costs several hundred milliseconds of server time, and lost packets that cost a full second of waiting. Total time alone would have shown “some requests are slow” and nothing more. The phase breakdown is what showed why, and that two of the three causes had nothing to do with my site.

checking the guesses, one request at a time Link to heading

Everything above is reasoning from a summary table, which is a polite way of saying it’s guessing. So I ran it again and saved every request with -csv:

$ ./probe -n 200 -csv run.csv https://bala-krishnan.com/

Different run, same pattern. Here are the requests whose connect took more than a second:

start                         dns_ms  connect_ms  tls_ms  wait_ms  total_ms  cf_cache_status
2026-10-24T11:48:39.515028Z    2.780    1033.710  70.644  134.689  1246.299  HIT
2026-10-24T11:48:40.962426Z    3.139    1035.039  42.351   46.835  1128.095  HIT
2026-10-24T11:48:51.146323Z    2.749    1023.770  35.649   31.578  1094.500  HIT
2026-10-24T11:49:00.72248Z     1.243    1046.356  52.269   81.324  1182.188  HIT
2026-10-24T11:49:11.614182Z    1.924    2032.697  41.270   42.713  2120.289  HIT

Four of them are about 1,030ms: one lost packet, one second of waiting, one normal 30ms handshake. Exactly what the summary suggested.

The fifth one is 2,033ms, and I like this one a lot. That’s two lost packets in a row: wait a second, retry, lost again, wait another second, retry, success. (RFC 6298 says the wait should double each time, which would make it 3 seconds. As far as I can tell, macOS doesn’t double the wait for its first few connection retries. I’d like to confirm that properly at some point.)

And here’s the cache miss:

start                         dns_ms  connect_ms  tls_ms  wait_ms  total_ms  cf_cache_status
2026-10-24T11:49:06.139661Z    2.260      43.573 126.479  734.677   907.571  EXPIRED

DNS, connect and TLS are all normal-ish. Almost all of the 908ms is in wait: 735ms of Cloudflare going back to Render to fetch a fresh copy, versus about 39ms for a typical cache hit. That’s the cost of a cache miss, measured directly.

Why does the cache expire at all? The response headers answer that:

$ curl -sI https://bala-krishnan.com/
cache-control: public, max-age=0, s-maxage=300
cf-cache-status: EXPIRED
rndr-id: eea1783b-6a44-4910
server: cloudflare
cf-ray: a4f8ee333f0c38d4-MAA

s-maxage=300 tells shared caches like Cloudflare to keep the page for 300 seconds. After five minutes, the next request has to go back to Render. (max-age=0 tells browsers not to keep it at all, so readers always get a fresh check.) My CSV run lasted about 75 seconds and saw exactly one EXPIRED, which fits: roughly one unlucky request every five minutes pays the 700ms trip back to Render, and everyone else gets a cache hit.

But why is that trip so expensive? DNS gives a clue. Following my domain’s records:

$ dig +short www.bala-krishnan.com
blog-ltnd.onrender.com.
gcp-us-west1-1.origin.onrender.com.
gcp-us-west1-1.origin.onrender.com.cdn.cloudflare.net.

The origin’s name is gcp-us-west1-1, and us-west1 is Google Cloud’s region in Oregon. If the name means what it looks like it means, then a cache hit is served from Chennai, a few hundred kilometres from me, while a cache miss has to go from Chennai to the US west coast and back, about halfway around the world. That would explain why a hit takes about 40ms and a miss about 700ms. Render doesn’t document where its origins are, so this is a guess from a hostname, but it’s a pretty good guess.

Two more small things the CSV showed:

  • Only the very first request had a slow DNS lookup (172ms). After that, my laptop had the answer cached and every lookup took about 2ms.
  • Of the 21 requests slower than 250ms, every single one except the cache miss was a Cloudflare HIT. Their biggest phase was connect in 10 of them, wait in 8 and TLS in 3. Apart from the five one-second cases, these are scattered delays of 150–450ms spread over different phases, which looks much more like Wi-Fi being Wi-Fi than like anything Cloudflare or Render did.

If I’d been looking at a single “response time” number, I’d have concluded my site is occasionally slow. Really, my site is fast, Cloudflare is fast, and my Wi-Fi in Kerala sometimes loses a packet.

what I’ve learned so far Link to heading

A few things showed up in the very first runs that I wouldn’t have seen with one curl:

  • Cache misses are expensive. In one run of 60 requests, 59 were Cloudflare cache HITs at about 130ms. One was EXPIRED, meaning Cloudflare’s copy had gone stale and it had to fetch the page from Render again. That one took 505ms.
  • Most of a first visit is connection setup. With a fresh connection each time, a typical request took about 129ms. Reusing the connection, it took about 46ms. Roughly two thirds of a first visit is DNS, TCP and TLS, not the server.
  • The breakdown tells you where to look. One evening three requests in a row timed out and the worst connect time was 5.6 seconds, but every response that came back was a fast cache hit. The problem was the network between my laptop and Cloudflare (probably my Wi-Fi), not the site. Without the phases I’d have blamed the server.

what it won’t do Link to heading

  • It measures from one place: wherever I run it. My numbers are from a laptop on Wi-Fi in Kerala, talking to Cloudflare in Chennai. They say nothing about a reader in another country.
  • It makes one request at a time. It’s not a load test.
  • It measures the network and the server, not the browser. It doesn’t download images or CSS, or render anything.
  • It’s not monitoring. It runs when I run it. (I might put it in a scheduled GitHub Action one day, which would get me a little closer.)

For those, the tools above are the right answer. For “how long does it take to fetch my blog, and where does that time go?”, 400 lines of Go turned out to be enough.

The code is on my GitHub.