TTFB is the most honest metric for the server side of a site: it shows how long a visitor waits before the server starts answering at all. Below we break that time into phases, separate the network from the application's own work, and go through what fixes each part — from database indexes to response caching in nginx. It is written for people who administer their own VPS or dedicated server running a PHP application.

What TTFB actually is

TTFB (Time To First Byte) is the time from the start of the request to the page until the first byte of the response arrives, including DNS resolution and the TCP and TLS handshakes. It is not a synonym for "how long PHP thought": the metric covers everything that happens before the application even learns about the request.

It helps to split TTFB into four phases — each has its own causes and its own remedies.

PhaseWhat happensWhat shortens it
DNSThe client resolves the domain to an IP. On repeat visits it usually comes from the resolver cacheSensible record TTLs, reliable DNS hosting, dns-prefetch on the page side
TCPThe three-way handshake — one round trip (RTT)keep-alive so it is not repeated on every request; a server closer to the audience
TLSThe handshake: one RTT with TLS 1.3, two with TLS 1.2TLS 1.3, session resumption and tickets, a short certificate chain. OCSP stapling is moot with Let's Encrypt certificates since 2025 — they no longer carry an OCSP URL
ResponseThe request travels to the server, the application builds the response, the first byte comes back: another RTT plus the backend's workResponse caching, database indexes, OPcache, moving heavy operations to queues

Code and configuration only affect the last row — and even that row contains a full network round trip. Worth remembering whenever someone promises "50 ms TTFB" to an audience on another continent.

How to measure it

Phase breakdown with curl

curl prints per-phase timings without a single extra tool:

curl -s -o /dev/null -w '
dns:   %{time_namelookup}
tcp:   %{time_connect}
tls:   %{time_appconnect}
pre:   %{time_pretransfer}
ttfb:  %{time_starttransfer}
total: %{time_total}
' https://example.com/

The values are cumulative and in seconds: each one includes the previous ones. So DNS is time_namelookup, the TCP handshake is time_connect minus time_namelookup, and TLS is time_appconnect minus time_connect.

This is where a common mistake starts. The difference time_starttransfer minus time_appconnect is not "the backend's work". It contains at least one full RTT: the request still has to reach the server and the first byte has to come back. There is also a separate pretransfer phase between appconnect and the request going out, and over plain http:// without TLS, time_appconnect is zero, which breaks the formula outright.

Count it like this: time_starttransfer minus time_pretransfer is the round-trip latency plus the time to generate the response; subtract one RTT from it (for example time_connect minus time_namelookup) to get an estimate of the server-side part. For the exact figure, still use $upstream_header_time from the nginx logs.

A single measurement means nothing

The first request lands on cold caches — OPcache, database buffers, the filesystem cache. Warm the page up separately and collect statistics over a series:

for i in $(seq 1 3); do curl -s -o /dev/null https://example.com/; done   # warm-up, excluded from the stats

for i in $(seq 1 30); do
  curl -s -o /dev/null -w '%{time_starttransfer}\n' https://example.com/
done | sort -n | awk '{a[NR]=$1} END {
  print "median:", (NR%2 ? a[(NR+1)/2] : (a[NR/2]+a[NR/2+1])/2);
  print "p95:", a[int(NR*0.95 + 0.9999)]
}'

Rounding up in the last line is not nitpicking: over 30 samples, int(NR*0.95) gives the 28th element, which is the 93rd percentile, not the 95th. The median shows the typical visitor, p95 shows the unlucky ones. A rising p95 with a stable median usually means contention for a resource: database locks, too few FPM workers, swapping.

Backend time comes from the nginx log

curl measures from the outside, network included. To see the application's own time, add timings to the log. Where you put them matters: log_format is allowed only in the http context, while access_log is valid in http, server and location. Put both snippets into your site's server block and nginx will refuse to start with [emerg] "log_format" directive is not allowed here.

# /etc/nginx/nginx.conf, http context
log_format timing '$remote_addr "$request" $status '
                  'rt=$request_time uct=$upstream_connect_time '
                  'uht=$upstream_header_time urt=$upstream_response_time';

# site config, server context
access_log /var/log/nginx/timing.log timing;

Apply it and validate the config in one command:

sudo nginx -t && sudo systemctl reload nginx

What the variables mean:

  • $request_time — the full request processing time, from the first byte of the request to the last byte sent to the client. A slow client connection is included.

  • $upstream_header_time — the time from the start of the connection to PHP-FPM until the response headers arrive; over a unix socket this is essentially the pure time the application spent generating the response.

  • $upstream_response_time — the same, but up to the last byte from FPM. The gap between the two shows how long the application streamed the body.

  • $upstream_connect_time is almost always 0.000 over a unix socket — that is normal, the variable is only useful for TCP upstreams.

After that, sorting is enough:

# the 20 slowest application responses
grep -o 'uht=[0-9.]*' /var/log/nginx/timing.log | cut -d= -f2 | sort -rn | head -20

The backend: where hundreds of milliseconds go

Database queries

By contribution to TTFB the database is almost always in first place. Turn on the slow query log with a low threshold:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL long_query_time = 0.2;

SET GLOBAL lasts until the server restarts; to make it permanent, put the same parameters into /etc/mysql/mysql.conf.d/mysqld.cnf. A 0.2 second threshold is already generous for a web page — start there and lower it as you clean things up.

Inspect the query you found with the planner:

EXPLAIN ANALYZE
SELECT id, title, published_at
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 20;

EXPLAIN ANALYZE is available from MySQL 8.0.18 and actually executes the query, not just builds a plan. On a live database that is one more run of a heavy scan, with all the locking and disk reads it entails. In production use plain EXPLAIN, and run EXPLAIN ANALYZE against a replica or a copy.

The usual outcome: a composite index covering both the filter and the sort is missing.

ALTER TABLE posts ADD INDEX posts_status_published_at_index (status, published_at);

The second permanent source of delay is N+1: one query for the list plus one per related record. Twenty posts turn into 21 queries, and on a slow disk that is already hundreds of milliseconds:

// N+1: 1 query for the list + one per post author
$posts = Post::query()->latest('published_at')->take(20)->get();

// 2 queries instead of 21
$posts = Post::query()->with('author')->latest('published_at')->take(20)->get();

To stop such places from reappearing, enable strict mode outside production — lazy loading will then throw an exception while you are still developing:

// app/Providers/AppServiceProvider.php, boot() method
Model::preventLazyLoading(! $this->app->isProduction());

Sessions and the application cache

File-based sessions and a file cache hit the disk and do not survive horizontal scaling. Move them to Redis:

sudo apt install redis-server php-redis
sudo systemctl restart php8.3-fpm
# php8.3-redis exists only with the ondrej/php PPA enabled;
# in the stock Ubuntu 24.04 repositories the package has no version in its name
php -m | grep redis

Restarting FPM is mandatory: on Debian and Ubuntu packages the trigger usually fires by itself, but with the PPA or a PECL build it does not, and the extension silently fails to load.

# .env
SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis

After editing .env, run php artisan config:clear, or php artisan config:cache if the config is cached. Without it the application keeps reading the old values.

While we are here, let us fix a widespread claim about session locking. With native PHP sessions (session.save_handler = files) the session file is locked for the duration of the request, and parallel requests from one user queue up. Laravel uses its own FileSessionHandler without locking — there the same setup produces not a queue but a write race: the last parallel request overwrites the session changes made by the others, plus extra IOPS. Both cases are cured by moving sessions to Redis.

OPcache and deployment

Without OPcache, PHP parses and compiles the sources on every request. A minimal working set for PHP 8.3:

# /etc/php/8.3/fpm/conf.d/99-opcache.ini
opcache.enable=1
opcache.memory_consumption=192
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

validate_timestamps=0 removes the mtime check on every file on every request — and with it the ability to pick up new code automatically. Every deployment then needs sudo systemctl reload php8.3-fpm, otherwise visitors keep getting the previous version of the application. If your deployment process does not guarantee that, leave the value at 1.

The framework caches belong here too: php artisan config:cache, route:cache, view:cache — or the single php artisan optimize. With a cached config, env() calls outside the config/ files return null; that is the classic first-production trap.

Response caching in nginx

Everything above shortens the application's work. Response caching removes it entirely: nginx serves ready-made HTML without waking PHP-FPM. It is the single largest TTFB win — and the most dangerous one if configured in a hurry.

Storage and conditions

# /etc/nginx/nginx.conf, http context
fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=app:100m
                   inactive=10m max_size=1g use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";

map $http_cookie $skip_cache {
    default 0;
    "~*(^|;\s*)logged_in="  1;
}

use_temp_path=off is not cosmetic. Without it nginx first writes the response into a temporary directory (/var/lib/nginx/fastcgi) and then moves it into the cache; if those are different filesystems, every MISS pays for an extra copy.

sudo mkdir -p /var/cache/nginx/fcgi
sudo chown www-data:www-data /var/cache/nginx/fcgi
# on RHEL and AlmaLinux the user is called nginx: chown nginx:nginx

Now about $skip_cache. Checking Laravel's session cookie is a false lead: the application hands a session to everyone, anonymous visitors included, for the CSRF token and flash messages. From any guest's second request the condition fires and the cache switches itself off. On top of that, the cookie name is set by SESSION_COOKIE and by default derived from APP_NAME, so a hardcoded $cookie_laravel_session copied from someone else's config almost certainly will not match your reality.

The "logged in" signal belongs in a separate cookie that the application sets on login and deletes on logout:

// after a successful login
cookie()->queue(cookie()->forever('logged_in', '1'));

// on logout
cookie()->queue(cookie()->forget('logged_in'));

nginx matches the cookie name only, so Laravel's cookie encryption does not get in the way here.

The location block

location ~ \.php$ {
    try_files $uri =404;

    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;

    fastcgi_cache app;
    fastcgi_cache_valid 200 301 302 10m;
    fastcgi_cache_use_stale error timeout updating http_500 http_503;
    fastcgi_cache_background_update on;
    fastcgi_cache_lock on;

    fastcgi_cache_bypass $skip_cache $http_authorization;
    fastcgi_no_cache     $skip_cache $http_authorization;

    fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
    fastcgi_hide_header    Set-Cookie;

    add_header X-Cache $upstream_cache_status always;
}

Let us go through the non-obvious lines.

try_files $uri =404; comes first not for speed. Without it, with cgi.fix_pathinfo=1 and a directory of user uploads, a request like /uploads/avatar.jpg/x.php reaches FPM and can execute the uploaded file. Configs from performance articles get copied wholesale, so this line should be in them by default.

fastcgi_cache_use_stale has its own parameter set, different from proxy_cache_use_stale: error | timeout | invalid_header | updating | http_500 | http_503 | http_403 | http_404 | http_429 | off. The familiar http_502 is not there — nginx will refuse to start with [emerg] invalid parameter "http_502". The nasty part is that nginx -t then fails, systemctl reload never runs, the server keeps serving the old config, and you conclude that "the cache just didn't kick in".

fastcgi_ignore_headers is needed because nginx by default does not cache responses carrying Set-Cookie or Cache-Control: no-cache/no-store/private. Laravel sends both on practically every page, so without this directive you get X-Cache: MISS every single time — with no error in the logs at all.

fastcgi_ignore_headers Set-Cookie without fastcgi_hide_header Set-Cookie is a straight leak: the first visitor's Set-Cookie lands in the cache and gets shipped to everyone else along with the page. Those two directives only ever go in together. Know the side effect in advance too: cached pages stop serving a fresh CSRF token, so forms on them must fetch the token with a separate request.

Verification

sudo nginx -t && sudo systemctl reload nginx
curl -sI https://example.com/ | grep -i x-cache

The first request is a MISS, the second a HIT. If HIT never shows up, work through the list:

  • the response carries Set-Cookie and fastcgi_ignore_headers is not configured;

  • the request matches $skip_cache — in a browser that is easy to check in the developer tools, while curl sends no cookies at all;

  • the method is neither GET nor HEAD — only those are cached by default;

  • nginx has no permissions on the cache directory — look for entries about it in error.log.

Compression

Let us be upfront: compression barely affects TTFB — the first byte leaves at roughly the same moment, sometimes a couple of milliseconds later. It shortens the full load time. But since you are editing the config anyway, there is a trap there worth a paragraph.

gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css text/javascript application/javascript application/json
           text/xml application/xml image/svg+xml;

The trap is in gzip_types. Since nginx 1.23.4 (and 1.24 is the stable branch built from it) the .js extension in conf/mime.types is served as text/javascript, not application/javascript. Lists copied from older articles without text/javascript leave JS bundles uncompressed, and the only way to notice is the response size. text/html does not need to be in the list — it is always compressed.

On Ubuntu 24.04, Brotli comes as modules from the repository:

sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
sudo nginx -t && sudo systemctl reload nginx

The packages alone compress nothing — the directives are what does the work, otherwise the modules sit installed while responses go out exactly as before:

brotli on;
brotli_comp_level 5;
brotli_static on;
brotli_types text/plain text/css text/javascript application/javascript application/json
             text/xml application/xml image/svg+xml;

brotli_static on; serves pre-compressed .br files when they sit next to the original. For built frontend bundles that is the best option: maximum compression ratio with no CPU cost per request.

Geography and RTT

Now the network — as an arithmetic example, not a measurement. Say the RTT between visitor and server is 120 ms. A cold HTTPS connection costs three round trips: the TCP handshake, TLS 1.3, and finally the request going out and the first byte coming back. That is roughly 360 ms before the application does anything at all. Plug in your own RTT from ping or from the difference between time_connect and time_namelookup — the arithmetic is the same.

Two practical conclusions follow. First: cut the number of handshakes — keep-alive, HTTP/2, session resumption; inside an already open connection only one of those three RTTs remains. Second: if the application generates response headers in 800 ms, moving the server will remove part of those network hundreds of milliseconds, but the main contributor stays where it is — caching and database queries first, geography second.

The reverse holds too. When $upstream_header_time already sits at 20-30 ms and your audience's TTFB still runs past half a second, there is nothing left to optimize in the code: from there on it is the server's location and serving static files closer to the user.

The order of work

  1. Take a baseline: a series of curl measurements with a separate warm-up, median and p95.

  2. Enable timings in the nginx log and find the pages with a large $upstream_header_time.

  3. Deal with the database: slow queries, missing indexes, N+1.

  4. Move sessions and cache to Redis, turn on OPcache and the framework caches.

  5. Only then enable response caching in nginx — on an unfixed application it masks problems instead of solving them.

  6. Repeat the measurement with the same script and compare it against the baseline.

Verify every step by measuring, not by feel. Otherwise nobody will remember a month later which change actually helped and which one merely added lines to the config.