← back to latest

Find out which part of an HTTP request is actually slow

curl -sS -o /dev/null -w "dns   %{time_namelookup}\ntcp   %{time_connect}\ntls   %{time_appconnect}\nttfb  %{time_starttransfer}\ntotal %{time_total}\n" https://www.debian.org

anatomy

curl
Transfer a URL. The part that matters here is that libcurl already keeps a stopwatch on every stage of the request for its own bookkeeping, whether or not you ask for it. You are not adding instrumentation, you are reading a measurement curl was going to take anyway.
-sS
Two flags that argue with each other on purpose. `-s` silences the progress meter, which otherwise draws a table over your timings on stderr. `-S` puts error messages back. Without the `S` a failed request prints nothing and you are left reading a row of zeros with no explanation.
-o /dev/null
Send the response body to nowhere. You want the timings, not 15 KB of HTML in your scrollback. This discards only the body: curl still downloads it, so the total time remains honest, and errors still reach stderr because they never travelled through this path.
-w
Short for `--write-out`. Print a format string after the transfer finishes, expanding `%{variable}` from curl's own record of what happened. It understands `\n` and `\t`, so you can lay the output out as a table. A literal percent sign has to be doubled as `%%`. Reading the string from a file works too: `-w @timing.txt`.
%{time_namelookup}
Seconds from the start of the command until DNS resolution finished. This is the phase people forget they are measuring, and it is the one most likely to be someone else's fault.
%{time_connect}
Seconds from the start until the TCP handshake completed. Subtract `time_namelookup` and what is left is one network round trip, which makes this the cheapest available estimate of your distance to the server.
%{time_appconnect}
Seconds from the start until the TLS handshake completed. Subtract `time_connect` for the cost of the handshake itself, usually one or two more round trips. It reads `0.000000` on plain HTTP and on a reused connection, because in both cases no handshake happened.
%{time_starttransfer}
Seconds from the start until the first byte of the response body arrived. The gap between this and `time_appconnect` is the server thinking: routing, database queries, template rendering. This is the only number in the list that is about the application rather than the network.
%{time_total}
Seconds for the whole operation. Subtract `time_starttransfer` to get how long the body took to arrive once it started, which separates a slow server from a large response.

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.243069 does 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_namelookup went 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 -L you are timing the redirect, not the page. A request to a bare hostname often returns a 301 in 50 ms and reports that as total, which looks like excellent performance until you notice http_code is not 200. Add -L and the arithmetic changes: time_total and time_starttransfer then cover the whole chain, time_redirect covers everything before the final request began, and the final response’s own think time is time_starttransfer minus time_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

lineage

curl exists because Daniel Stenberg wanted an IRC bot to quote currency exchange rates. In 1996 he picked up httpget, a small tool by Rafael Sagula, to fetch the rates from a web page, then kept extending it: FTP, more options, a name change to urlget, and by 1998 enough protocols that the name no longer fit. The commonly cited version is that curl, released in March 1998, was chosen for the sense of seeing a URL, with the C for client. In 2000 the guts were split into libcurl so other programs could embed the transfer engine without shelling out, and that decision is why the tool went everywhere: libcurl is in cars, televisions, printers, game consoles and phones, and Stenberg has spent years collecting sightings of it in places nobody told him about. The exchange rate bot is long gone. The utility written to feed it now runs, by most accounts, on billions of devices.