How to Measure TTFB Without Fooling Yourself
“Your TTFB is 800 milliseconds.” That sentence is repeated in audit reports, Slack threads and sales calls as though it were a fact about the website. It is not. It is a fact about one request, from one place, at one moment, measured from a starting point nobody stated — and on a CDN-backed site, most of it is usually not the server’s fault.
TTFB is the easiest performance metric to measure incorrectly, and the one where a wrong reading leads people to optimise the wrong thing. Here is how to take it apart.
Three different numbers wear the same name
The confusion starts with the definition, because “time to first byte” does not specify from when.
- From navigation start (the moment the request begins): this includes DNS lookup, TCP handshake, TLS negotiation, and then the server’s response. This is what a browser reports to a real-user monitoring tool, and it is the closest thing to what a visitor experiences.
- From request start: measures only the time between “the request is on the wire” and “the first response byte arrives” — DNS, TCP and TLS already excluded. This is what the Navigation Timing API calls
responseStart - requestStart, and it is the number that actually describes server behaviour. - Rendered page TTFB as a lab tool sees it: many synthetic tools report a single figure without telling you which of the above it is, and the gap between the two definitions can be several hundred milliseconds on a site where TLS negotiation is slow.
So the first rule is to stop reading TTFB as a single value. Ask which span it covers, or measure it yourself and you will know.
Splitting it with curl
curl will report each phase of the request separately, which turns one ambiguous number into four unambiguous ones:
curl -s -o /dev/null -w \
'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer}\n' \
https://example.com/
Read them as cumulative checkpoints, not independent durations:
time_namelookup— DNS resolution finishedtime_connect— TCP handshake finishedtime_appconnect— TLS handshake finishedtime_starttransfer— first response byte arrived
Each number is measured from the start of the request, so the segment durations are the differences. time_starttransfer on its own is the full “from navigation start” TTFB; time_starttransfer - time_appconnect is the server’s contribution, the part you can actually tune on the server side.
One trap in that first field: if your machine or resolver has the name cached, time_namelookup returns something near zero and you have not measured DNS at all. To see real resolution times you need a cold cache or a resolver with caching disabled.
What it looked like on a real site
I ran this against this site — an Astro build on Cloudflare Pages, four consecutive requests to the same URL over a single connection path, with no other traffic involved:
| Run | DNS | TCP | TLS | TTFB to first byte |
|---|---|---|---|---|
| 1 | 0.1 ms | 2.0 ms | 463 ms | 1033 ms |
| 2 | 0.2 ms | 3.1 ms | 486 ms | 1062 ms |
| 3 | 0.1 ms | 1.4 ms | 395 ms | 1765 ms |
| 4 | 0.1 ms | 1.4 ms | 455 ms | 998 ms |
Four useful things fall out of that table.
Attention goes to the wrong column. DNS resolution and the TCP handshake together accounted for roughly 4 ms. People spend hours on DNS performance when it is a rounding error next to everything else.
TLS was the largest single component — 395 to 486 ms, around half the total. On an HTTPS site the TLS handshake is frequently the biggest contributor to TTFB, and it has nothing to do with how fast your server renders pages.
Server time was the smaller half. Subtracting the TLS handshake leaves 570, 576, 1370 and 543 ms — the actual time from “connection ready” to “first byte”, which for a static site on an edge platform includes routing, cache lookup and response generation.
Run-to-run variance was nearly two to one, on the same URL, from the same machine, a second apart. 998 ms and 1765 ms are the same request to the same site. Any conclusion drawn from a single measurement is noise.
The honest caveat about that data
Those numbers came from a connection routed through a proxy whose exit was in Europe — the edge node serving the request identified itself as London on one pull and Amsterdam on another. I am not measuring from the location of any real audience. The TLS figure, in particular, is dominated by the round-trip time along that path.
That is not a flaw in the method; it is the lesson. TTFB is mostly a measurement of the distance between the test point and the edge node, not a property of the site. The same origin measured from Frankfurt and from Sydney will not produce numbers within 10% of each other, and neither is wrong. When a tool reports a TTFB figure without telling you where it ran, you have learned nothing you can act on.
This is also why a cache hit and a cache miss can look identical in a single sample. Here is the same site’s stylesheet — a hashed, immutable asset with Cache-Control: public, max-age=14400, served as cf-cache-status: REVALIDATED — against the homepage HTML, which carries max-age=0, must-revalidate and reports cf-cache-status: DYNAMIC:
| Asset | Cache status | TTFB range over 4 runs |
|---|---|---|
Stylesheet (_astro/…css) | REVALIDATED | 902–1206 ms |
| Homepage HTML | DYNAMIC | 998–1765 ms |
The ranges overlap almost entirely. Caching is genuinely doing work here, but the tens of milliseconds it saves vanish inside hundreds of milliseconds of network variance. A single-location synthetic test cannot resolve cache-level differences at all — it is measuring the wrong scale of thing.
Four ways a test measures the wrong request
You measured the redirect. Requesting http:// and following redirects adds the entire second request — a fresh connection, a fresh TLS handshake, then the response — to the total. In my runs, the http:// version came in at 1.49 to 2.95 seconds against 1.00 to 1.76 for https:// directly. That is not a slow server; it is two requests wearing a trench coat. Test the final URL, and if you must test the redirect, report it separately.
You measured a warm path. Repeat requests to the same host reuse DNS caches, TLS session tickets and keep-alive connections. That is realistic for a returning visitor and completely unrepresentative of a first-time one. Measure both, and label which is which.
You measured from the wrong place. A synthetic tool runs from a data centre that is probably not near your users. Use several regions, or better, do not use synthetic data for judgement at all.
You optimised the wrong layer. TTFB on a static site served from an edge network is usually fine, and the interesting reduction is not available anyway. Time spent shaving 50 ms off edge TTFB is time not spent on the LCP element, which is where the consumer-visible number actually lives.
What to do instead
Use synthetic tests to locate a problem, real-user data to judge one. A curl breakdown tells you where time is going on one path: DNS, handshake, or server. Chrome UX Report data, Cloudflare Web Analytics, and Search Console’s Core Web Vitals report tell you what actual visitors experienced, aggregated across real networks and devices. Only the second is evidence about your site’s performance; the first is evidence about a route.
Take a distribution, not a sample. Ten runs, then look at the median and the worst case. One run is a coin flip, and reporting a p50 while hiding a p95 that is five times higher is how people get surprised by the users who matter.
Remember TTFB is a floor, not a score. Largest Contentful Paint cannot be faster than the time it takes the first byte to arrive, so TTFB sets the lower bound for LCP. That means it is worth fixing when it is bad, and worth ignoring once it is reasonable — because below that point your LCP is being decided by images, fonts, and render-blocking resources, not by the server.
Check what your platform’s cache is actually doing. cf-cache-status tells you whether the request was answered from cache, revalidated, or handled dynamically. If your HTML is DYNAMIC and your assets are HIT, you know which one to be curious about. If you are optimising a path that already reports HIT, you are tuning numbers nobody is waiting on.
The practical version of all this: measure TTFB on the final URL, in at least two locations, ten times each, and split it into its phases before you conclude anything. Then go and look at your largest image.
Written by TestedHost. Every recommendation on this site comes from running the setup described, on a live deployment — not from a vendor spec sheet. Spotted something out of date? Tell us.