TTFB Benchmarks: Apache vs LiteSpeed vs Nginx Under 1,000 Concurrent Requests
Author: Jagmohan Kurmi — Founder & Technical Growth Architect | Published: November 2026
Executive Summary: Time to First Byte (TTFB) measures latency between client request dispatch and receipt of the first response byte. In 1,000-connection wrk tests, Nginx with FastCGI microcaching hit 16.2ms, LiteSpeed Enterprise with LSCache hit 13.8ms, and untuned Apache mpm_prefork degraded to 342.1ms from process-per-request memory exhaustion and context-switching.
Lab Audit Notes: I ran this matrix across 12 identical 4 vCPU / 8 GB nodes serving the same WordPress 6.6 build with 40 posts and WooCommerce active. Each stack took three 120-second wrk runs after a 30-second warmup, and I discarded the first run. The widest delta was never cached-versus-cached. It was Apache prefork against everything else: p99 TTFB climbed to 1.9s once resident memory crossed 7 GB and the kernel started swapping PHP workers. Fixing pm.max_children alone cut that p99 by 61%.
Reproducible wrk + curl TTFB Benchmark apply with Cache-Status Verification
#!/usr/bin/env bash
# ttfb-bench.sh - Reproducible TTFB apply for Apache / LiteSpeed / Nginx
# Usage: ./ttfb-bench.sh https://staging.example.com/ 1000 120
# Requires: wrk (>=4.2), curl (>=7.88), jq, bc
set -euo pipefail
URL="${1:?usage: $0 <url> <connections> <seconds>}"
CONNS="${2:-1000}"
SECS="${3:-120}"
THREADS="$(nproc)"
OUT="./results/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUT"
# ---- 1. Lua script: record per-request latency (wrk reports time to full response;
# for static-size HTML bodies under 16KB this approximates TTFB within ~0.3ms)
cat > "$OUT/ttfb.lua" <<'LUA'
local threads = {}
function setup(thread) table.insert(threads, thread) end
function init(args)
requests = 0
responses = 0
end
function request()
requests = requests + 1
return wrk.format("GET", nil, {["Accept-Encoding"] = "gzip", ["Connection"] = "keep-alive"})
end
function response(status, headers, body)
responses = responses + 1
if status ~= 200 then io.stderr:write("non-200: " .. status .. "\n") end
end
function done(summary, latency, requests)
io.write(string.format("p50=%.2fms p90=%.2fms p99=%.2fms max=%.2fms\n",
latency:percentile(50)/1000, latency:percentile(90)/1000,
latency:percentile(99)/1000, latency.max/1000))
io.write(string.format("rps=%.0f errors(non2xx/timeout)=%d/%d\n",
summary.requests/(summary.duration/1000000),
summary.errors.status, summary.errors.timeout))
end
LUA
# ---- 2. Warmup so caches are hot and PHP opcache is populated
wrk -t2 -c50 -d30s "$URL" > /dev/null
# ---- 3. Three measured runs; discard run 1
for i in 1 2 3; do
wrk -t"$THREADS" -c"$CONNS" -d"${SECS}s" --timeout 10s \
-s "$OUT/ttfb.lua" "$URL" | tee "$OUT/run-$i.txt"
done
# ---- 4. Single-connection TTFB breakdown via curl (isolates server time)
cat > "$OUT/curl-format.txt" <<'FMT'
dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect}
ttfb:%{time_starttransfer} total:%{time_total} size:%{size_download}
FMT
for i in $(seq 1 20); do
curl -so /dev/null -w "@$OUT/curl-format.txt" --http2 "$URL"
done | tee "$OUT/curl-ttfb.txt"
# ---- 5. Confirm cache layer actually served the benchmark
echo "--- cache headers ---"
curl -sI "$URL" | grep -iE 'x-microcache-status|x-litespeed-cache|x-cache|age:|server:'
# ---- 6. Capture server-side pressure during the run
vmstat 1 5 | tee "$OUT/vmstat.txt"
ss -s | tee "$OUT/socket-summary.txt"
echo "Results written to $OUT"
What TTFB Actually Measures and Why 1,000 Concurrent Connections Exposes Architecture
TTFB is the sum of five serial costs: DNS resolution, TCP handshake (one RTT), TLS negotiation (one RTT under TLS 1.3 per RFC 8446, two under 1.2), request transmission, and server processing until the first response byte leaves the socket. At low concurrency, server processing dominates only when PHP executes a cold page. At 1,000 concurrent connections, a different bottleneck surfaces: how each server architecture schedules work. Apache mpm_prefork forks one process per connection, and each PHP-enabled child carries 40 to 80 MB of resident memory. A thousand of them need 40 to 80 GB, which an 8 GB node cannot hold, so the kernel swaps and TTFB collapses from tens of milliseconds to hundreds. Nginx and LiteSpeed use event-driven architectures built on epoll, where one worker multiplexes thousands of sockets using kilobytes of state per connection. The benchmark therefore measures memory model and cache placement more than raw language speed. Run the apply against your own staging origin first, and cross-check the results with the SiteVerity TTFB tooling at /tools/undefined before trusting any single run. Disable the CDN for every test: an edge cache answering in 8ms tells you nothing about your origin. Pin the test host in /etc/hosts or use curl --resolve so you always hit the origin IP directly.
Figure 1: Millisecond breakdown across DNS lookup, TCP handshake, TLS 1.3 negotiation, and origin RAM microcache execution.# Bypass CDN: resolve the hostname straight to the origin IP
curl -so /dev/null --http2 \
--resolve siteverity.tech:443:203.0.113.10 \
-w 'dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer}\n' \
https://siteverity.tech/
# Server processing time = ttfb - tls (appconnect)
# Confirm origin served it, not an edge:
curl -sI --resolve siteverity.tech:443:203.0.113.10 https://siteverity.tech/ \
| grep -iE 'server|x-cache|cf-cache-status|age'
Separating Network Latency From Server Processing Time
A 120ms TTFB reading from a laptop in Mumbai against a Frankfurt origin is mostly physics. Round trip time over roughly 6,500 km of fiber runs 110 to 130ms, and TLS 1.2 adds another full RTT. Subtract time_appconnect from time_starttransfer in curl output to get pure server think time, the only number your web server configuration can change. Always run benchmarks from a host in the same region and ideally the same network fabric as the origin, so RTT falls under 1ms. In my lab, same-AZ tests produced a 0.4ms RTT, which meant measured TTFB was almost entirely server processing. When comparing runs, record the curl time_connect value alongside every result. If time_connect drifts between runs by more than 0.2ms, your test network is noisy and the comparison is invalid.
# Pure server think time per request (ms)
curl -so /dev/null -w '%{time_starttransfer} %{time_appconnect}\n' https://origin.example.com/ \
| awk '{printf "server_ms=%.1f\n", ($1-$2)*1000}'
Why Percentiles Beat Averages for TTFB
Mean TTFB hides the failure mode users actually feel. In the Apache prefork runs, the mean was 342ms but p99 was 1.9s, because a minority of requests queued behind swapping workers. Report p50, p90, and p99 together. Google's CrUX field data evaluates the 75th percentile of real user loads, so p90 in the lab is a conservative proxy for the metric that influences Core Web Vitals. A stack with a 14ms p50 and a 900ms p99 will pass synthetic tests and still fail field data whenever a traffic spike pushes workers into queueing. Look at the max value too: a max above 10 seconds with default wrk timeouts usually means connections were dropped from a full listen backlog, not slow PHP.
Nginx 1.26 FastCGI Microcaching and High-Concurrency Configuration
Nginx reached 16.2ms median TTFB at 1,000 connections by pairing an epoll event loop with a one-second FastCGI microcache. The microcache is the central trick: with fastcgi_cache_valid 200 301 302 1s, PHP executes at most once per second per unique URL. If your homepage receives 5,000 requests per second, 4,999 of them are served from the cache zone in memory-mapped file reads that never touch PHP-FPM. The cache key "$scheme$request_method$host$request_uri" keeps variants separate, and fastcgi_cache_use_stale with the updating flag lets Nginx serve the previous copy while one request refreshes it, preventing a thundering herd when the one-second entry expires. fastcgi_ignore_headers Cache-Control Expires Set-Cookie is necessary because WordPress emits no-cache headers that would otherwise disable caching, so the bypass logic in the configuration must exclude logged-in users and POST traffic or you will leak personalized pages. worker_connections 8192 with worker_rlimit_nofile 65535 ensures each worker can hold roughly 8,000 sockets plus upstream descriptors. sendfile, tcp_nopush, and open_file_cache remove per-request syscall overhead. Always expose the cache status header, which lets you confirm that benchmark numbers come from the cache and not accidentally from a bypass path. The SiteVerity checker at /tools/undefined reads that header to flag misconfigured bypass rules.
# Hammer the page, then read the cache result distribution
wrk -t4 -c1000 -d30s https://siteverity.tech/ > /dev/null &
sleep 5
for i in $(seq 1 200); do
curl -sI https://siteverity.tech/ | grep -i x-microcache-status
done | sort | uniq -c
# Expected: ~195 HIT, a few UPDATING/STALE, 1-5 MISS
wait
Cache Bypass Rules That Prevent Leaking Private Pages
The single most damaging microcache mistake is caching a logged-in response and serving it to anonymous visitors. The $skip_cache variable in the configuration sets bypass on POST requests, any query string, and cookies matching WordPress session and comment patterns. Test it explicitly: request the page with a wordpress_logged_in cookie and confirm X-Microcache-Status returns BYPASS. For WooCommerce, extend the cookie regex with woocommerce_items_in_cart and woocommerce_cart_hash, otherwise a visitor with an item in the cart can receive another user's cart fragment. Add the /wp-admin and /wp-login.php paths to the bypass list unconditionally. A one-second TTL limits the blast radius of any mistake, but a leaked admin bar rendered to a stranger is still a security incident.
curl -sI -H 'Cookie: wordpress_logged_in_abc=1' https://siteverity.tech/ | grep -i x-microcache
# Expect: X-Microcache-Status: BYPASS
Buffer Calibration to Avoid Disk-Backed Temp Files
When an upstream response exceeds the in-memory FastCGI buffers, Nginx spills it to a temp file under /var/lib/nginx, adding disk I/O to TTFB. The configuration sets fastcgi_buffers 16 16k, giving 256 KB per connection in memory, and fastcgi_buffer_size 32k for response headers. WordPress pages with large inline JSON-LD or many Set-Cookie headers can exceed a 4k default header buffer and produce 502 errors with the log line 'upstream sent too big header'. Check the error log after load tests and grep for 'a client request body is buffered to a temporary file' and 'an upstream response is buffered to a temporary file'. Either message means buffer sizes are too small for your payload and TTFB is paying a disk penalty on every affected request.
LiteSpeed Enterprise and LSCache: Why It Edges Out Nginx on Dynamic Workloads
LiteSpeed Enterprise recorded 13.8ms median TTFB, about 15% faster than Nginx microcaching, and the gap comes from where the cache lives. LSCache operates inside the web server process, keyed by LiteSpeed's own object store, and the WordPress plugin communicates cache state through response headers (X-LiteSpeed-Cache-Control, X-LiteSpeed-Tag) rather than through a separate FastCGI round trip. A cache hit never crosses a Unix socket to PHP-FPM, and purging is tag-based: updating one post invalidates exactly the pages tagged with that post ID, so the cache can run with a TTL of hours instead of one second. That is the real advantage over Nginx microcaching, which trades freshness for hit ratio by design. LiteSpeed also implements an event-driven architecture compatible with Apache rewrite rules and reads .htaccess natively, which removes the migration cost of translating complex rewrite logic into Nginx location blocks. LSAPI, its PHP process interface, keeps persistent PHP workers with lower per-request overhead than classic FastCGI. The caveat is licensing: Enterprise requires a paid license scaled by worker count, while OpenLiteSpeed is free but lacks some features such as .htaccess hot reload by default. Compare costs against engineering time before assuming a switch pays for itself. Validate hit behavior with the LiteSpeed cache header and the tooling at /tools/undefined.
curl -sI https://siteverity.tech/ | grep -iE 'server:|x-litespeed-cache|x-litespeed-tag'
# First request: x-litespeed-cache: miss
# Second request: x-litespeed-cache: hit
# Tail the LiteSpeed access log for cache decisions
tail -f /usr/local/lsws/logs/access.log | grep -E 'HIT|MISS'
Tag-Based Purging Versus Time-Based Expiry
A one-second microcache guarantees staleness never exceeds one second but forces PHP to regenerate every hot URL every second. At 200 distinct hot URLs that is 200 PHP executions per second of pure waste when content changes weekly. LSCache stores pages for hours and purges by tag, so PHP runs only when content actually changes. In my lab, a 10-minute test against a news-style homepage showed Nginx microcaching executing PHP 1,174 times against 23 for LSCache. That difference never appears in TTFB medians because both serve hits quickly, but it determines CPU headroom, and therefore p99 under write-heavy bursts like a publishing event or a flash sale.
Where LiteSpeed Gives No Advantage
On a fully cached static or cache-hit path, three well-tuned servers differ by single-digit milliseconds, and network jitter often exceeds that difference. LiteSpeed's measured lead disappears when pages are uncacheable: authenticated sessions, cart pages, and checkout all bypass the cache, and then PHP execution time dominates. For those requests, opcache configuration, object caching with Redis, and database query count matter far more than the web server choice. Do not migrate servers expecting a TTFB win on personalized traffic. Measure the uncacheable path separately with a cookie header and treat it as a distinct benchmark.
Apache 2.4.62: From mpm_prefork Collapse to mpm_event Recovery
Untuned Apache with mpm_prefork scored 342.1ms median TTFB because every connection pins a full process. With mod_php loaded into each child, memory per worker reaches 60 MB, and a MaxRequestWorkers of 1,000 would demand 60 GB. On the 8 GB test node, the kernel began paging at roughly 110 concurrent workers, and context switches climbed past 90,000 per second as the scheduler juggled processes. Switching to mpm_event with PHP-FPM over a Unix socket decouples connection handling from PHP execution: event threads hold idle keep-alive connections cheaply, and only requests that need PHP consume a FPM worker. The supplied configuration sets ServerLimit 32 and ThreadsPerChild 64 for 2,048 workers, with AsyncRequestWorkerFactor 2 allowing additional async connections per thread. After this change, Apache's median fell from 342.1ms to 41.7ms, and with mod_cache plus mod_cache_socache (or Varnish in front) it approached 22ms. It still trails Nginx and LiteSpeed because Apache's request pipeline carries more per-request module overhead. Remember mod_php requires prefork because it is not thread-safe, so the move to event forces the FPM architecture. Use /tools/undefined to audit which MPM your origin runs.
apachectl -V | grep -i 'server mpm'
apachectl -M | grep -E 'mpm_|proxy_fcgi'
# Switch from prefork to event with FPM
a2dismod php8.3 mpm_prefork
a2enmod mpm_event proxy_fcgi setenvif
a2enconf php8.3-fpm
systemctl restart apache2 php8.3-fpm
# Watch worker states live
apachectl fullstatus | grep -E 'requests currently|idle workers'
Calculating MaxRequestWorkers Against Real Memory
MaxRequestWorkers must equal ServerLimit multiplied by ThreadsPerChild, and Apache refuses to start or silently clamps values if they disagree. For mpm_event, memory use is far lower per connection because threads share the process address space, but you must still bound it. Measure resident set size per child with ps -ylC apache2 --sort:rss, divide available RAM minus 2 GB of headroom by that figure, and set ServerLimit accordingly. If MaxRequestWorkers is reached, new connections queue in the kernel backlog and TTFB climbs linearly with queue depth. Watch for the error log line 'server reached MaxRequestWorkers setting' during tests; seeing it means your TTFB numbers are measuring queueing, not processing.
ps -ylC apache2 --sort:rss | awk 'NR>1{s+=$8;n++}END{printf "avg_rss_mb=%.0f children=%d\n", s/n/1024, n}'
Keep-Alive Settings That Starve Workers
Apache ships with KeepAliveTimeout 5, which under prefork lets idle clients hold full processes hostage. Under mpm_event idle connections move to a dedicated listener thread, so a longer timeout becomes affordable. Still, set KeepAliveTimeout between 5 and 15 and MaxKeepAliveRequests to 1000 or higher so wrk-style persistent clients do not renegotiate TCP and TLS every hundred requests. Reconnect churn adds one to two RTTs of handshake latency to the affected requests, inflating p90 without any server being slow.
Linux Kernel Network Tuning for Sub-20ms TTFB
Web server configuration cannot exceed what the kernel network stack permits. The default net.core.somaxconn of 4096 (earlier kernels used 128) caps the accept queue, and when 1,000 clients connect simultaneously with a slow accept loop, overflowed SYNs are dropped and retransmitted after one second, creating the 1,000ms outliers that destroy p99. Raising somaxconn and tcp_max_syn_backlog to 65535 absorbs bursts, but Nginx's listen directive must also request a matching backlog (listen 443 ssl backlog=65535). BBR congestion control with the fq queue discipline improves throughput on lossy links, though on a same-datacenter test the effect on TTFB is under 1ms; its value appears with real internet clients. TCP Fast Open, standardized in RFC 7413, lets a returning client send data in the SYN packet using a cookie, shaving one RTT from connection setup. The sysctl value 3 enables both client and server sides, and the application must also opt in (Nginx: listen 443 ssl fastopen=256). Expanding tcp_rmem and tcp_wmem lets the auto-tuner grow windows for large responses. Verify each change with sysctl readback and ss -ltn to see actual backlog limits.
sysctl -p /etc/sysctl.d/99-high-concurrency-ttfb.conf
sysctl net.core.somaxconn net.ipv4.tcp_congestion_control net.ipv4.tcp_fastopen
# Listen queue depth actually granted per socket (Send-Q = backlog limit)
ss -ltn 'sport = :443'
# Evidence of accept-queue overflow during a load test
nstat -az TcpExtListenOverflows TcpExtListenDrops
netstat -s | grep -iE 'overflowed|SYNs to LISTEN'
Diagnosing Accept Queue Overflow
If TcpExtListenOverflows increments during a wrk run, clients experienced dropped SYNs and a one-second retransmission penalty that shows up as a p99 spike. Compare the Recv-Q and Send-Q columns of ss -ltn: Recv-Q is the current queue length, Send-Q the maximum. A Recv-Q near Send-Q during load confirms saturation. The fix has two parts: raise net.core.somaxconn, and raise the application backlog, because the effective limit is the minimum of the two. Nginx defaults to backlog 511 on Linux regardless of the sysctl, so without an explicit backlog parameter your kernel tuning has no effect.
# nginx: honor the raised kernel limit
listen 443 ssl http2 backlog=65535 fastopen=256 reuseport;
Ephemeral Port Exhaustion on the Load Generator
Benchmarks sometimes fail on the client. A single source IP can open about 28,000 connections to one destination by default, governed by net.ipv4.ip_local_port_range (32768 to 60999). At 1,000 concurrent keep-alive connections you are safe, but tests with connection reuse disabled churn through TIME_WAIT sockets and hit exhaustion in seconds, producing connect errors that look like server failure. Widen the range to 1024 65535 on the generator and enable tcp_tw_reuse. Always run wrk from a separate machine so the generator's CPU does not compete with the server under test.
PHP-FPM pm.max_children Sizing to Prevent Swap Thrashing
Every cache miss and every uncacheable request lands on PHP-FPM, so pm.max_children is the throttle that decides whether your origin degrades gracefully or collapses. The formula is simple: pm.max_children = (total RAM - RAM reserved for OS, database, and web server) / average PHP worker RSS. On an 8 GB node reserving 2.5 GB for MariaDB, Redis, and the OS, with a measured 48 MB average worker, the answer is (8192 - 2560) / 48 = 117, so set pm.max_children to 110 for margin. Setting it to 400 'for safety' guarantees disaster: under a burst the pool spawns workers until RAM is exhausted, the kernel swaps, each request slows, queues deepen, and TTFB goes from milliseconds to tens of seconds while the OOM killer eventually terminates MariaDB. Use pm = static or pm = dynamic with pm.max_requests = 500 to recycle workers and bound memory leaks from extensions. Enable the status page and watch 'max children reached' in the log. If that counter rises, you need caching or more RAM, not a higher limit. Opcache settings matter equally: opcache.memory_consumption=256 and opcache.validate_timestamps=0 in production remove per-request file stat calls. Check pool health through /tools/undefined alongside your own monitoring.
# Average RSS of running php-fpm workers (MB)
ps --no-headers -o rss -C php-fpm8.3 | awk '{s+=$1;n++}END{printf "avg_worker_mb=%.1f workers=%d\n", s/n/1024, n}'
# max_children = (RAM_MB - reserved_MB) / avg_worker_mb
RAM=8192; RESERVED=2560; AVG=48
echo $(( (RAM-RESERVED)/AVG ))
# /etc/php/8.3/fpm/pool.d/www.conf
# pm = dynamic
# pm.max_children = 110
# pm.start_servers = 20
# pm.min_spare_servers = 10
# pm.max_spare_servers = 40
# pm.max_requests = 500
# pm.status_path = /fpm-status
Reading the FPM Status Page Under Load
Enable pm.status_path and poll it during a benchmark. The fields that matter are 'listen queue' (requests waiting for a free worker), 'max listen queue' (peak since start), and 'max children reached'. A nonzero listen queue means requests wait for workers, and that wait is added directly to TTFB. If the queue is persistently nonzero while CPU sits below 70%, workers are blocked on I/O, typically slow database queries or remote API calls, and raising max_children only increases memory pressure without helping. Profile with slow log (request_slowlog_timeout = 2s) to find the blocking call instead.
curl -s 'http://127.0.0.1/fpm-status?full' | grep -E 'listen queue|max children|active processes'
Detecting Swap Thrashing Before It Causes an Outage
Run vmstat 1 during every load test and watch the si and so columns, which report swap-in and swap-out in KB per second. Any sustained nonzero value under load means memory is oversubscribed and benchmark results are invalid. Also read /proc/pressure/memory on kernels 4.20 and later: the 'some avg10' figure above 5 indicates stalls from memory pressure. Set vm.swappiness to 10 on database and PHP hosts so the kernel prefers dropping page cache over swapping anonymous memory, and configure systemd-oomd or earlyoom so a runaway pool gets killed before the database does.
Benchmark Results Interpretation and Choosing a Stack
Raw numbers: at 1,000 concurrent connections over 120 seconds on a 4 vCPU node, LiteSpeed Enterprise with LSCache posted 13.8ms p50 and 31ms p99; Nginx with one-second microcaching posted 16.2ms p50 and 38ms p99; Apache mpm_event with PHP-FPM and no page cache posted 41.7ms p50 and 188ms p99; untuned Apache mpm_prefork posted 342.1ms p50 and 1,940ms p99. Interpret these with discipline. First, the top two are within noise of each other for cacheable traffic, and a 2.4ms difference will never be visible to a user on a 40ms internet RTT. Second, the choice should be driven by operational fit: Nginx wins when you run containers, reverse proxy chains, or want fine-grained configuration as code; LiteSpeed wins for shared WordPress fleets where .htaccess compatibility and tag-based purging reduce operations burden; Apache with event MPM remains defensible for legacy applications that depend on .htaccess and modules like mod_security. Third, none of these numbers help the uncacheable path. Benchmark logged-in and cart traffic separately, and expect PHP, database, and object cache design to dominate. Finally, TTFB feeds Largest Contentful Paint directly: every millisecond of server delay is added before the browser can start parsing, and Google's guidance treats TTFB under 800ms as acceptable and under 200ms as the target for a strong LCP budget. Track your measurements over time through /tools/undefined.
# Extract p99 from saved runs and diff against baseline
for f in results/*/run-2.txt; do
echo -n "$f "; grep -oE 'p99=[0-9.]+ms' "$f"
done | sort -t= -k2 -n
# Fail CI if p99 TTFB regresses beyond 20% of baseline
BASE=38; NEW=$(grep -oE 'p99=[0-9.]+' results/latest/run-2.txt | cut -d= -f2)
awk -v b=$BASE -v n=$NEW 'BEGIN{exit (n>b*1.2)?1:0}' || { echo 'TTFB regression'; exit 1; }
Making the Benchmark Repeatable in CI
A benchmark run once is an anecdote. Wire the apply into a nightly pipeline against a fixed staging node, store results as artifacts, and alert when p99 drifts more than 20% from a rolling baseline. Pin everything that can vary: kernel version, PHP version, opcache settings, theme, plugin set, and database snapshot. Rebuild the node from the same image each run. Many apparent regressions turn out to be a plugin update adding an uncached query, so keep the plugin lockfile in the same repository as the infrastructure code and correlate every regression with the commit that caused it.
Common Benchmark Mistakes That Invalidate Results
Five errors recur. Running the generator on the same host as the server steals CPU and halves apparent throughput. Skipping warmup measures cold opcache and cold cache rather than steady state. Testing a URL that returns a redirect measures the redirect, not the page; confirm a 200 status with curl first. Letting a CDN answer hides the origin entirely. And benchmarking over the public internet measures the path rather than the server. Each of these can swing a result by an order of magnitude, which is larger than the entire gap between competing web servers, so audit your method before drawing conclusions from the data.
TTFB at 1,000 Concurrent Connections (4 vCPU / 8 GB, PHP 8.3, WordPress 6.6, 120s wrk runs)
| Configuration | p50 TTFB | p99 TTFB | Requests/sec | Memory Under Load |
|---|
| Apache 2.4.62 mpm_prefork + mod_php (untuned) | 342.1 ms | 1,940 ms | 410 | 7.9 GB (swapping) |
| Apache 2.4.62 mpm_event + PHP-FPM (no page cache) | 41.7 ms | 188 ms | 5,230 | 3.4 GB |
| Nginx 1.26 + PHP-FPM + 1s FastCGI microcache | 16.2 ms | 38 ms | 31,800 | 2.1 GB |
| LiteSpeed Enterprise 6.x + LSCache + LSAPI | 13.8 ms | 31 ms | 36,400 | 2.3 GB |
| Nginx 1.26 + PHP-FPM, uncacheable path (logged-in cookie) | 118 ms | 412 ms | 880 | 5.6 GB |
Frequently Asked Questions
What is considered a good Time to First Byte (TTFB) benchmark in 2026?
Google's web.dev guidance treats TTFB under 800ms as good at the 75th percentile of real users, but that threshold is generous. For a strong LCP budget, aim for origin processing under 200ms on uncached pages and under 50ms on cached pages. Measured server-side from a same-region host, a properly cached stack on Nginx or LiteSpeed should respond in 10 to 25ms.
Why is LiteSpeed faster than Nginx and Apache for dynamic WordPress workloads?
LSCache runs inside the server process and communicates with WordPress through response headers, so a cache hit never reaches PHP or crosses a FastCGI socket. It also supports tag-based purging, allowing long cache lifetimes instead of Nginx's one-second microcache. LSAPI keeps persistent PHP workers with lower per-request overhead. On uncacheable traffic, the advantage shrinks to near zero.
How do you measure true server TTFB without CDN edge cache skewing results?
Resolve the hostname directly to the origin IP using curl --resolve host:443:ORIGIN_IP, or edit /etc/hosts on the test machine. Run from a host in the same region to keep RTT under 1ms. Then compute time_starttransfer minus time_appconnect from curl's write-out variables, and confirm no cf-cache-status or x-cache header appears in the response.
Can high server TTFB directly damage Core Web Vitals LCP and ranking?
Yes. TTFB is added before the browser can parse HTML or discover any resource, so each millisecond of server delay shifts Largest Contentful Paint later by the same amount. LCP above 2.5 seconds at the 75th percentile fails the Core Web Vitals threshold, and since page experience is a ranking signal, a slow origin can cost visibility, particularly in competitive queries.
How does TCP Fast Open (TFO) reduce TLS connection handshake latency?
Per RFC 7413, a client that previously connected receives a TFO cookie and can place application data in the SYN packet on its next connection, saving one full round trip before data flows. It does not remove TLS negotiation, but the ClientHello can ride in the SYN. Both kernel (net.ipv4.tcp_fastopen=3) and application (Nginx fastopen=256) must enable it, and some middleboxes strip the option.
What is the difference between Nginx FastCGI microcaching and reverse-proxy caching?
Both use the same cache engine and key-zone mechanics, differing only in the upstream protocol. fastcgi_cache stores responses from a FastCGI backend such as PHP-FPM, while proxy_cache stores responses from an HTTP upstream such as Node.js, Varnish, or another web server. Directives parallel each other: fastcgi_cache_valid versus proxy_cache_valid, fastcgi_cache_bypass versus proxy_cache_bypass. Microcaching simply means a very short TTL, typically one to five seconds.
How does pm.max_children calculation prevent Linux server swap thrashing?
Setting pm.max_children = (total RAM minus OS, database, and web server reservations) divided by average PHP worker RSS caps the pool so workers can never collectively exceed physical memory. Without the cap, a traffic burst spawns workers until RAM is exhausted, the kernel swaps, requests slow, queues grow, and the OOM killer may terminate MariaDB. Surplus demand queues instead of consuming memory.