>
Open Source

How to actually measure your CDN from the Linux command line

A fast load time on the laptop you happen to be sitting in front of is not evidence that your content delivery network (CDN) is working for the rest of the planet. CDN is a network of edge servers distributed around the world that cache copies of your static files close to users, so a request from Tokyo does not have to round-trip to a single origin in Virginia. The whole point of the architecture is geographic distribution. Testing it from a single point tells you nothing about that distribution. You can ship a website that loads in 180 ms from your office and takes six seconds to load from São Paulo, and your dashboard will show green the entire time.

The fix is small, free, and already on your Linux box. Two commands (curl and dig) plus a few DNS lookups against globally distributed resolvers will tell you what your CDN is actually doing for users outside your network. This piece walks through the shape of a useful test, the metrics that matter, the cache headers that prove the CDN is engaged, and the regional sampling that turns a guess into a measurement.

Split the request into stages before you try to optimize any of them

A load time is a single number that hides five stages. If you only see the number, you cannot tell which stage is the problem, and most of the advice about CDNs assumes the problem is the same stage on every site. It is not. DNS might be fast on every region except one. The TLS handshake (the cryptographic negotiation that establishes a secure HTTPS connection) might dominate on mobile networks. The time-to-first-byte (TTFB, the gap between sending a request and getting the first byte of the response back) might balloon when the CDN has to fall back to your origin.

The curl flag -w writes a custom output template after the transfer finishes, which lets you print each stage on its own line:

curl -sS -o /dev/null -w '
DNS: %{time_namelookup}s
Connect: %{time_connect}s
TLS: %{time_appconnect}s
TTFB: %{time_starttransfer}s
Total: %{time_total}s
IP: %{remote_ip}
' https://example.com/

The time_namelookup variable is the time to resolve the domain to an IP. time_connect is the TCP handshake. time_appconnect is the TLS handshake on top. time_starttransfer is the gap between request sent and first byte of response, also called TTFB. time_total is the whole thing. Run that once and you have a profile. Run it from a handful of regions and you have a measurement. Run it every week and you have a trend.

A single profile gives you more than a single number, because it points at the right thing to fix:

  • High DNS time usually means a slow authoritative resolver or a TTL (time-to-live, the cache lifetime) that is too short.
  • High connect time usually means the routing from your test box to the edge is bad, not the CDN.
  • High TLS time usually means a heavy certificate chain or a long key exchange, both fixable on the CDN side.
  • High TTFB with normal connect time usually means the edge is doing real work, often because it forwarded to your origin.

If you only have ten minutes, this is the test. It is the difference between arguing about CDN speed and knowing which part of it is slow.

Verify the CDN is actually serving from cache

A fast response does not prove the CDN is doing its job. The edge can forward every request to your origin and still return the bytes quickly, if your origin is fast and the network is short. That defeats the whole point of the architecture. The cheapest way to tell is to read the response headers.

curl -s -o /dev/null -D - https://example.com/ | grep -iE '(cache|age|served-by)'

Two headers are diagnostic. The Age header tells you how many seconds the object has been sitting in the edge cache. A high Age number means it has been there a while, which means your origin has not been getting hit for that object. The cache status header tells you what the edge decided on this specific request:

  • Cloudflare uses CF-Cache-Status with values HIT, MISS, EXPIRED, REVALIDATED, BYPASS, or DYNAMIC.
  • Fastly uses X-Cache (HIT or MISS) plus X-Cache-Hits counting how many times the object has been served from cache.
  • CloudFront uses X-Cache formatted as Hit from cloudfront or Miss from cloudfront.
  • Akamai historically required the Pragma: akamai-x-cache-on request header to expose its X-Cache diagnostic; modern Akamai tenants should use the authenticated Enhanced Debug mechanism instead.

A fast response with MISS means the edge passed the request through to your origin. A fast response with HIT means the edge served it. The two are not interchangeable, and most teams only ever see the first one.

One pitfall worth naming: curl -I sends a HEAD request, and most CDNs treat HEAD differently from GET. Real users send GET. Testing with HEAD can show you a cache status that does not match what your users actually see. The command above sends a normal GET and discards the body, which is the closest approximation to a real request you can get from the command line.

Sample from more than one place, because the planet is not flat

The whole reason you bought a CDN is geography. A test from one box measures exactly one geography. The cheap way to get geographic coverage is to ask public DNS resolvers in different regions to resolve your hostname, then measure the response time to the IP each one returns. The hostname is the same. The IP you get back is whatever that region considers your closest edge, which is what a user in that region would also see.

A useful list of public resolvers to sample against:

  • 1.1.1.1 (Cloudflare, anycast, usually the closest edge to you, good as a baseline)
  • 8.8.8.8 (Google, anycast, same idea, different network)
  • 208.67.222.222 (OpenDNS, anycast)
  • 9.9.9.9 (Quad9, anycast)
  • 4.2.2.2 (Level3, legacy but useful as a fourth independent data point)

Loop over the list, resolve the hostname through each one with dig +short @{resolver} yourdomain.com, then curl the resulting IP. Different resolvers in different regions give you different IPs, and the difference between those IPs is the CDN’s footprint. If the resolvers in North America, Europe, and Asia all return IPs in the same /16 (a block of about 65,000 IP addresses), you have one edge location and the CDN is not doing what you think it is doing.

The pattern that always works:

  • Pick five resolvers from the list.
  • Resolve the hostname through each.
  • curl each resolved IP and capture the timing profile.
  • Compare the connect time and TTFB across the rows.
  • A wide gap between regions is a routing problem. A wide gap between TTFBs with similar connect times is an origin problem. A wide gap between cache statuses is a configuration problem.

DNS time is its own measurement, and most teams never look at it

DNS is the slowest part of a typical first request, and the part most teams never profile. Run dig +stats yourdomain.com and look at the Query time line. On a warm cache this is sub-50 ms. On a cold cache (first request after the TTL expires, or first request from a region that has never seen the domain) it can be 200-400 ms. If you can feel the difference between those two numbers, your users can too.

The fix is not exotic. Use a low TTL on the apex (the bare domain) only when you are actively migrating between CDNs. Use a long TTL on the static subdomain (the cdn.yourdomain.com host that points to your edge) so the resolver caches it. Pick a CDN with a wide anycast footprint (a network design where one IP is announced from many physical locations, so the router picks the nearest one) so the first hop from the resolver is already close. Run dig from a few different resolvers every quarter to make sure the answer is still the answer you expected.

Trade-offs

This kind of testing is not free. Each curl profile is one request; a regional sweep of ten resolvers at three times of day is thirty requests per sweep, and you will want to do this weekly at minimum, which is real traffic on a small origin. Pick a small static asset for the test (/favicon.ico is a good choice: it exists on almost every site, has a long cache lifetime, and the body is too small to hide TTFB noise. Avoid testing against your homepage, because most homepages change often enough that the cache will miss every time and you will measure origin latency instead of edge latency.

Two more honest costs. First, DNS sampling through public resolvers only tells you what those resolvers see. Real users behind corporate proxies, mobile carriers, or country-level DNS firewalls will see something different, and you cannot reproduce those from a Linux box in your office. The cheap measurement is a useful proxy, not a substitute for real-user monitoring. Second, the cache headers you read with curl are diagnostic-only on most CDNs, and a few providers change them between releases. The pattern is stable, the exact header name is not, so expect to read the docs for your specific CDN before treating any specific header as gospel.

The honest upside is that you can build the whole harness in a shell script on a tiny box and run it from cron for the cost of a few DNS queries a week. The expensive alternative is a paid synthetic monitoring product. For most small sites, the cheap version is enough to catch the regressions that actually matter: a CDN misconfiguration, a routing change that pushes a region to the wrong edge, or an origin that has gotten slow enough to dominate TTFB.

What I would tell past me

If I could send a message back to the version of me that first tried to debug a CDN issue from a single curl to the homepage, I would say three things.

  • One box is not a region. A test from your office tells you about your office. Sample at least three resolvers on three continents before you decide the CDN is fast or slow.
  • Fast is not cached. A 100 ms response can still be a MISS, which means the CDN is forwarding to your origin and you are paying for nothing. Always check the cache status header, not the timing.
  • Measure the stages, not the total. time_total is a single number that hides five stages. If you only optimize the total, you will fix whichever stage was loudest last week and miss the one that actually matters on mobile.

The move for this week is short. Save the curl profile as a shell script, point it at a static asset, and run it from cron against five public resolvers. The first time the cache status comes back as MISS on a region you care about, you will know exactly what the rest of the year is going to look like.

Leave a comment