Your page feels slow and you want a number. Time to first byte (TTFB) is the one to start with: how long from "send the request" until the first byte of the answer shows up. curl can measure it, and it can also tell you which part of that wait to blame.
The command
curl -s -o /dev/null -w "%{time_starttransfer}\n" https://symfony.com
# 0.082498
That's TTFB in seconds: 82 ms. -o /dev/null throws the page away, -s hides the progress bar, and -w prints a timing variable when the transfer ends.
One number isn't much use, though. Run it five times against two sites:
symfony.com 0.082 0.075 0.071 0.079 0.100
laravel.com 0.352 0.487 0.291 0.312 0.278
laravel.com is about four times slower to first byte. Why? TTFB alone can't say. It's the sum of four different waits, and only one of them is the server's fault.
Break it into parts
Put this in a file called curl-format.txt:
dns: %{time_namelookup}s\n
tcp: %{time_connect}s\n
tls: %{time_appconnect}s\n
sent: %{time_pretransfer}s\n
ttfb: %{time_starttransfer}s\n
total: %{time_total}s\n
Then point curl at it:
curl -s -o /dev/null -w "@curl-format.txt" https://laravel.com
dns: 0.001104s
tcp: 0.025593s
tls: 0.050740s
sent: 0.050830s
ttfb: 0.478745s
total: 0.803300s
The catch that trips everyone up: these are timestamps, not durations. Each line is the time since the start, so you subtract to get how long a step took.
| Step | How to get it | laravel.com |
|---|---|---|
| DNS lookup | dns |
1 ms |
| TCP connect | tcp minus dns |
24 ms |
| TLS handshake | tls minus tcp |
25 ms |
| Waiting on the server | ttfb minus sent |
428 ms |
| Downloading the body | total minus ttfb |
325 ms |
Now it's obvious. The network part is done in 51 ms. Then curl sends the request and sits there for 428 ms until the server starts answering. That's the backend: building the page, querying a database, waiting on a cache miss. No CDN setting fixes that number.
(The 1 ms DNS is a cache hit. My machine had looked the name up seconds before. A cold lookup on the same connection takes 20 to 35 ms.)
Which part to blame
DNS is slow (over 100 ms). Your DNS provider is slow or far away, or your TTLs are so short that nobody ever gets a cached answer. Check a second run: if it drops to near zero, the first was a cold cache and that's normal.
TCP connect is slow. That's distance, almost always. Connect time is one round trip to the server, so it's the best measure you have of how far away it is. This one surprised me:
www.postgresql.org
tcp: 0.013908s tcp: 0.189627s
tls: 0.062443s tls: 0.412440s
ttfb: 0.095919s ttfb: 0.604857s
Same URL, six times slower. I first saw it as one run at 25 ms and the next at 180, with nothing changed. postgresql.org has mirrors in Salzburg, Oslo and Dallas, and DNS hands them out in turn. The two columns are curl pinned to Salzburg and to Dallas with --resolve, to get clean numbers. From Switzerland, Salzburg is 14 ms away and Dallas is 190 ms. Look at what that does to everything after it: the TLS handshake and the request each cost another round trip, so a 190 ms connect turns into a 605 ms TTFB with a server that's just as fast. To see which one you got, add %{remote_ip} to the format.
TLS is slow but TCP is fast. The handshake should cost about one round trip, the same as connect. If it's several times that, look at a long certificate chain, OCSP checks, or a server that isn't doing TLS 1.3.
Waiting on the server is slow. Everything before sent was fine and then nothing came back. That's your application. Run it a few times: if only the first run is slow, you're looking at a cold cache (on mozilla.org I got 317 ms on the first hit and 35 ms on the second). If every run is slow, it's the code.
Download is slow. A big page or a thin pipe. laravel.com's homepage is 540 KB of HTML, which explains its 325 ms. curl doesn't ask for compression unless you tell it to, so check what a browser would get:
curl -s -o /dev/null -w "%{size_download}\n" https://laravel.com
# 540479
curl -s -o /dev/null -H "Accept-Encoding: gzip, br" -w "%{size_download}\n" https://laravel.com
# 182385
A third of the size, so compression is on and browsers have it easier than this test suggests. If both numbers match, it's off, and that's your fix.
The part curl can't do
curl measures from where you're sitting. My numbers come from a laptop in Switzerland, on a good connection, close to most European servers. Your users aren't there. A site that looks fast from your desk can be the Dallas case for half your traffic.
The Performance API runs the same measurement from our server instead, and does the subtraction for you. It's one location, not a global probe network. What it gives you is a second vantage point: a clean data centre connection with none of your office Wi-Fi, VPN or ISP in the numbers.
curl -G "https://apixies.io/api/v1/performance" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://laravel.com"
{
"status": "success",
"data": {
"url": "https://laravel.com",
"final_url": "https://laravel.com",
"http_status": 200,
"content_length": 540527,
"redirect_count": 0,
"primary_ip": "104.18.2.81",
"timings": {
"dns_lookup_ms": 0.58,
"tcp_connect_ms": 15.96,
"ssl_handshake_ms": 28.54,
"ttfb_ms": 280.88,
"server_wait_ms": 235.74,
"content_download_ms": 108.83,
"total_time_ms": 389.7,
"redirect_time_ms": 0
}
}
}
These are durations, not timestamps. (The half millisecond of DNS is a warm cache again.) ttfb_ms is the same thing as curl's time_starttransfer, and server_wait_ms is the "ttfb minus sent" row from the table: the backend's share, with no network time in it. Same story as before, 236 of 281 ms spent waiting on the server.
It follows redirects too, and keeps that time apart. http://github.com comes back with redirect_count: 1 and redirect_time_ms: 53.92, so you can see what the hop to HTTPS costs.
You can run it from the browser with the Website Performance tool, no key needed.
Check a list of URLs
This prints one line per URL and exits with an error if any server takes more than 500 ms to start answering. That makes it usable in cron or CI.
Bash
#!/bin/bash
# ttfb-check.sh url1 url2 ...
LIMIT_MS=500
FAILED=0
for URL in "$@"; do
WAIT=$(curl -s -G "https://apixies.io/api/v1/performance" \
-H "X-API-Key: $APIXIES_API_KEY" --data-urlencode "url=$URL" \
| jq -r '.data.timings.server_wait_ms // "error"')
echo "$URL server wait: ${WAIT} ms"
if [ "$WAIT" = "error" ] || [ "${WAIT%.*}" -gt "$LIMIT_MS" ]; then
FAILED=1
fi
done
exit $FAILED
JavaScript
async function timings(url) {
const params = new URLSearchParams({ url });
const res = await fetch(`https://apixies.io/api/v1/performance?${params}`, {
headers: { "X-API-Key": process.env.APIXIES_API_KEY },
});
const body = await res.json();
if (body.status !== "success") {
throw new Error(`${body.code}: ${body.message}`);
}
return body.data.timings;
}
Python
import os
import requests
def timings(url):
res = requests.get(
"https://apixies.io/api/v1/performance",
params={"url": url},
headers={"X-API-Key": os.environ["APIXIES_API_KEY"]},
timeout=60,
)
body = res.json()
if body["status"] != "success":
raise RuntimeError(f"{body['code']}: {body['message']}")
return body["data"]["timings"]
PHP
function timings(string $url): array
{
$context = stream_context_create(['http' => [
'header' => 'X-API-Key: ' . getenv('APIXIES_API_KEY'),
'ignore_errors' => true,
]]);
$body = json_decode(file_get_contents(
'https://apixies.io/api/v1/performance?' . http_build_query(['url' => $url]), false, $context
), true);
if ($body['status'] !== 'success') {
throw new RuntimeException("{$body['code']}: {$body['message']}");
}
return $body['data']['timings'];
}
What's a good number
Google's guidance calls a TTFB under 800 ms good and over 1,800 ms poor, measured for real users. That's generous, because it has to cover phones on bad networks. From a server or a desk on a wired connection, a healthy site does the network part in under 100 ms and answers in under 200. If server_wait_ms is steadily above 500, that's worth a look before anything else.
One measurement tells you almost nothing. Run it a few times, at different hours, and watch the server wait. The rest mostly takes care of itself.
Next steps
- Performance API reference: every field in the response
- Website Performance tool: try a URL in the browser
- Automated SSL Certificate Monitoring: the same cron setup, for certificates
- All guides