Skip to content

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

Try the Website Performance Analyzer API

Free tier is for development & small projects. 75 requests/day with a registered account.

cookies

We use analytics cookies to see how the site gets used. Nothing loads until you accept. Privacy policy