Sample output
dns 0.181871
tcp 0.207050
tls 0.243069
ttfb 0.294615
total 0.294655
Read the gaps, not the numbers. DNS alone burned 0.18 of a 0.29 second request, roughly sixty percent of the wait, before a single packet went to the web server. The TCP handshake added 25 ms, TLS another 36 ms, and the server took 52 ms to produce a first byte. The body then arrived in 40 microseconds, which is the shape of a small page on a fast link. Nothing here is a server problem, and no amount of tuning the application would have moved it.
When you would reach for it
Someone reports that the site is slow and the server graphs look fine. A health check times out intermittently and you cannot tell whether it is the network or the service. A deploy to a new region feels sluggish and you want a number before you open a ticket with anyone. This turns “it feels slow” into five figures that point at exactly one stage, which is usually enough to know whose problem it is.
Gotchas
- Every value is measured from the moment curl started, so they are running totals, not durations.
tls 0.243069does not mean the TLS handshake took 243 ms, it means everything up to the end of the handshake took 243 ms. Read each stage as the difference from the line above it. Taken literally the list makes DNS look free and the last stage look enormous, which is backwards. - The first run measures a real resolver round trip and the second reads a cache. On the same domain, seconds apart,
time_namelookupwent from 0.038562 to 0.001516 here, a factor of twenty five. Run it three times before you conclude anything about DNS, and if you want the cold number, use a domain you have not touched today. - Without
-Lyou are timing the redirect, not the page. A request to a bare hostname often returns a 301 in 50 ms and reports that astotal, which looks like excellent performance until you noticehttp_codeis not 200. Add-Land the arithmetic changes:time_totalandtime_starttransferthen cover the whole chain,time_redirectcovers everything before the final request began, and the final response’s own think time istime_starttransferminustime_redirect. The DNS and connect numbers keep describing the first connection rather than the one that served the page you actually read. - A typo in a variable name is silent. Ask for
%{time_nosuchthing}and curl writes a complaint to stderr, substitutes nothing at all, and still exits 0. A script that parses this output gets an empty field and a success status, so check the format string by eye the first time rather than trusting the exit code to catch it.
Variants
$ curl -sS -o page.html -w '%{stderr}dns %{time_namelookup} ttfb %{time_starttransfer} total %{time_total}\n' https://www.debian.org
Keep the body and the timings at once by sending the report to stderr with %{stderr}, which frees stdout for the response. Useful in a pipeline where the body feeds another command and you still want the numbers on your terminal. Overwrites page.html if it already exists
$ for u in https://example.com https://www.debian.org https://api.github.com; do curl -sS -o /dev/null -w "%{time_starttransfer} $u\n" "$u"; done | sort -rn
Rank a list of endpoints slowest first. Putting the URL inside the format string is the trick: the number and its label come out on one line already, so sort -rn on the numeric first field is all the report needs
$ curl -sS -o /dev/null -w '%{json}' https://www.debian.org | jq '{dns: .time_namelookup, tls: .time_appconnect, ttfb: .time_starttransfer, total: .time_total, code: .http_code}'
Every variable curl tracks, emitted as one JSON object, then narrowed with jq. Reach for this when you are recording timings rather than reading them, since it survives a schema you did not anticipate. Needs jq and curl 7.70 or newer