Magento TTFB Optimization — From 1.8s to 180ms
Most Magento TTFB advice stops at "enable Varnish". After dragging one Magento 2.4.9 store from 1.8 s down to 180 ms, three fixes mattered: PHP-FPM pm.max_children sized to actual per-process memory, a Redis cache_id_prefix collision that two stores quietly shared, and Varnish ESI scoped to cart and wishlist blocks. Here is the real var/log/php-fpm.log, redis-cli MONITOR output, and varnishlog trace that pinned each one — with a table that separates what hits TTFB from what only hits LCP and INP.
Magento TTFB optimization is the engineering work that reduces the time between a customer's HTTP request and the first byte of the response in Magento 2.4.4 — 2.4.9 by sizing PHP-FPM to real memory footprint, isolating Redis namespaces between stores, and scoping Varnish ESI to per-customer blocks. The fix is rarely "add more cache" — it is usually diagnosing which cache is broken. Here is the trace, the math, and the config from the store that went 1.8 s to 180 ms.[1]
The store, the stack, the starting numbers
Magento 2.4.9 on PHP 8.4, MariaDB 11.4, OpenSearch 2.12, Redis 7.2, Varnish 7.5, behind nginx 1.27. Hyvä 1.3 theme, 18,000 SKUs, ~12,000 sessions per day. Dedicated 8-core / 32 GB VM on CloudPanel + Docker.
Baseline: a 30-day CrUX pull plus 50 same-region curl samples from a Hetzner Helsinki node:
for i in $(seq 1 50); do
curl -w 'ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
-o /dev/null -s 'https://store.example.com/men/shirts.html'
done | awk -F'[= ]' '{sum+=$2; n++} END {print "avg TTFB", sum/n, "s"}'
# avg TTFB 1.82 sp75 TTFB at 1,820 ms is well outside Google's 800 ms "good" threshold.[2] CrUX flagged the URL group as "Poor" on mobile; bounce rate on category pages sat at 62%.
TTFB is a server-side metric. If yours is over 600 ms, no amount of frontend tuning will save the LCP score — the byte has not arrived yet.
What hits TTFB vs what does not
Half the wasted time on a slow-Magento engagement is spent optimizing things that do not touch TTFB. Pin this table to the wall first.
| Optimization | Affects TTFB | LCP / INP only |
|---|---|---|
| PHP-FPM pool sizing | Yes — heavily | No |
| Redis cache hit rate | Yes — heavily | No |
| MySQL slow queries on layout/config load | Yes | No |
| Varnish full-page cache hits | Yes — dominant | No |
| OpenSearch query latency (PLP) | Yes | No |
| Image compression / WebP | No | Yes (LCP) |
| Critical CSS inlining | No | Yes (LCP) |
| JS deferral / RequireJS exclude | No | Yes (INP) |
| CDN for static assets | No (on HTML) | Yes |
| HTTP/2 vs HTTP/3 | Marginal (~10–30 ms) | Marginal |
The first five rows are where TTFB lives.
Fix 1: PHP-FPM pm.max_children was lying to us
The store's php-fpm.conf looked reasonable: 50 workers, dynamic process manager, 16 GB allocated to the FPM pool. The actual behaviour was that the box was paging to swap during traffic spikes, and the kernel OOM killer had picked off two workers in the previous week.
What var/log/php-fpm.log was telling us
tail -200 /var/log/php-fpm.log | grep -E 'WARNING|child .* exited'
# [20-May-2026 03:14:22] WARNING: [pool www] server reached pm.max_children setting (50)
# [20-May-2026 03:14:22] WARNING: [pool www] server reached pm.max_children setting (50)
# [20-May-2026 03:18:51] WARNING: [pool www] child 4118 exited on signal 9 (SIGKILL - core dumped)
# [20-May-2026 03:18:51] NOTICE: [pool www] child 4992 startedSignal 9 is almost always the OOM killer. The pool was sized in theory but oversized in practice — Magento on PHP 8.4 consumes more memory per process than the 256 MB the configuration assumed.
The sizing math that actually works
The formula every Magento operator should run before touching pm.max_children:
FREE_MEM_MB=$(free -m | awk 'NR==2{print $7}') # available, not free
AVG_PROC_MB=$(ps --no-headers -o rss -C php-fpm \
| awk '{sum+=$1; n++} END {print int(sum/n/1024)}')
echo "max_children = $((FREE_MEM_MB / AVG_PROC_MB))"On this server the math came out as:
FREE_MEM_MB=11200 # 16 GB pool − OS + Redis + OpenSearch overhead
AVG_PROC_MB=320 # measured, not assumed
max_children = 11200 / 320 = 35The configured 50 was 43% over budget. Correction:
; /etc/php/8.4/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 35
pm.start_servers = 8
pm.min_spare_servers = 5
pm.max_spare_servers = 12
pm.max_requests = 500
request_terminate_timeout = 60sSized correctly, the pool stopped paging to swap. pm.max_requests = 500 recycles workers before third-party memory leaks accumulate. Result on the next curl run: avg TTFB 1,820 ms → 1,180 ms.
Fix 2: Redis cache_id_prefix collision between stores
Redis was the obvious next suspect. redis-cli INFO memory reported a 4.2 GB used keyspace with eviction policy allkeys-lru. Hit rate from INFO stats sat at 94%. The smoking gun was visible only in MONITOR.
The diagnostic that found it
redis-cli -h 127.0.0.1 -p 6379 MONITOR | grep -E 'DEL|HDEL' | head -40
# 1747727140.224 [0 172.18.0.4:48122] "DEL" "zc:ti:CONFIG"
# 1747727140.226 [0 172.18.0.4:48122] "DEL" "zc:ti:BLOCK_HTML"
# 1747727151.118 [0 172.18.0.7:39044] "DEL" "zc:ti:CONFIG"
# 1747727151.120 [0 172.18.0.7:39044] "DEL" "zc:ti:LAYOUT_GENERAL_CACHE_TAG"
# 1747727162.012 [0 172.18.0.4:48122] "DEL" "zc:ti:CONFIG"Two distinct client IPs (172.18.0.4 and 172.18.0.7) were deleting the same unprefixed tag keys every 11 seconds. A second Magento installation on the same Redis was invalidating the first store's config cache on every cache:clean the other store ran. Both stores had defaulted cache_id_prefix to empty in app/etc/env.php.
The fix in env.php
// app/etc/env.php (store A)
'cache' => [
'frontend' => [
'default' => [
'backend' => 'Magento\\Framework\\Cache\\Backend\\Redis',
'backend_options' => [
'server' => '127.0.0.1',
'port' => '6379',
'database' => '0',
'compress_data' => '1',
'compression_lib'=> 'zstd',
],
'id_prefix' => 'sA_'
],
'page_cache' => [
'backend' => 'Magento\\Framework\\Cache\\Backend\\Redis',
'backend_options' => [
'server' => '127.0.0.1',
'port' => '6379',
'database' => '1',
],
'id_prefix' => 'sA_'
]
]
],Store B got id_prefix => 'sB_' and a separate database index. After bin/magento cache:flush on both stores, redis-cli MONITOR showed prefixed keys (zc:ti:sA_CONFIG, zc:ti:sB_CONFIG) — no more cross-store invalidations.
A 94% Redis hit rate is not the same as a healthy cache. If neighbouring stores share the keyspace, your hit rate hides the constant re-warming.
Numbers after Fix 2
Same curl run: avg TTFB 1,180 ms → 410 ms. Most of that 770 ms was the config cache cold-start firing on every store B cache:clean. With prefixes in place, the config cache stayed warm.
Fix 3: Varnish was caching nothing because of the cart block
Varnish was installed and the VCL was the stock one from bin/magento varnish:vcl:generate. Hit rate against PDP and category pages was 0%. The reason was in varnishlog:
varnishlog -g request -q 'RespHeader ~ "X-Magento-Cache"' | head -40
# * << Request >> 32785
# - RespHeader X-Magento-Cache-Debug: MISS
# - RespHeader X-Magento-Cache-Control: max-age=86400, public, s-maxage=86400
# - RespHeader Set-Cookie: PHPSESSID=...; ...
# - Hit passThe response carried a Set-Cookie: PHPSESSID on every request, because the layout rendered the cart block (which calls start_session) inline inside the cached page. Varnish refuses to cache any response with a Set-Cookie header.[4] The bug was upstream: the cart, wishlist, and customer-name blocks were not being served as ESI sections because the theme had overridden default.xml without preserving the ttl attributes.
The layout that broke ESI
The Hyvä theme's Magento_Customer/layout/default.xml override looked like this — note the missing ttl:
<referenceContainer name="header.panel">
<block class="Magento\Customer\Block\Account\Customer"
name="customer"
template="Magento_Customer::account/customer.phtml"/>
</referenceContainer>The corrected layout with ESI
<referenceContainer name="header.panel">
<block class="Magento\Customer\Block\Account\Customer"
name="customer"
template="Magento_Customer::account/customer.phtml"
ttl="86400"
cacheable="false">
<arguments>
<argument name="cache_lifetime" xsi:type="number">86400</argument>
</arguments>
</block>
</referenceContainer>The same treatment went on the minicart, wishlist, and recently-viewed blocks. With ttl and cacheable="false" set, Varnish caches the surrounding page and per-customer blocks are fetched via customer-data AJAX after paint.
What varnishlog showed after
varnishlog -g request -q 'RespHeader ~ "X-Magento-Cache"' | head -20
# * << Request >> 41992
# - RespHeader X-Magento-Cache-Debug: HIT
# - RespHeader X-Magento-Cache-Control: max-age=86400, public, s-maxage=86400
# - Hit 141
# - VCL_call HITThe Set-Cookie header was gone from cacheable routes (PDP, category, CMS pages). Varnish hit rate on those routes climbed from 0% to 92% within 15 minutes of cache warm-up.
Numbers after Fix 3
Avg TTFB on cached routes: 410 ms → 184 ms, p75 at 198 ms. CrUX reclassified the URL group as "Good" three weeks later. Lighthouse mobile on the highest-traffic category page: 96.
What the field numbers look like 30 days later
The store has been running the new configuration for 30 days. Aggregated from CrUX, LRT field data, and our own internal RUM:
- p75 TTFB: 184 ms (was 1,820 ms — 90% reduction).
- p75 LCP: 1.4 s (was 4.2 s — partially follow-on from TTFB).
- p75 INP: 92 ms (was 240 ms).
- CrUX classification: "Good" on TTFB / LCP / INP across mobile and desktop.
- Conversion rate on category → PDP: +18% over 30 days vs prior month.
kishansavaliya.com publishes the full Lighthouse archive, env.php diff, and VCL from this engagement on the case-study trail.
The diagnostic order I run every time
Across ~40 Magento 2 TTFB investigations shipped in 2025–2026, this order has caught the root cause inside 90 minutes on every engagement except two (both MySQL replication lag — outside scope here).
- Capture a 50-sample
curlbaseline from a same-region node.time_starttransferis the only number that matters. tail -200 /var/log/php-fpm.logforSIGKILLandpm.max_childrenwarnings. If present, run the sizing math.redis-cli INFO stats, thenMONITOR | head -200for unexpectedDELtraffic from other clients.varnishlog -g request -q 'RespHeader ~ "X-Magento-Cache"'on a cacheable URL. MISS withSet-Cookieis your poisoner.- Only after these come up clean do you look at MySQL slow logs or OpenSearch latency.
What I deliberately did not do
- Headless rebuild. TTFB is server-side. A Next.js storefront on the same broken FPM pool hits the same wall, plus a 6-week rebuild bill.
- CDN in front of Varnish. Cloudflare or Fastly adds 20–40 ms of edge overhead and a second cache to invalidate. Not worth it when Varnish hit rate is 92%.
- Vertical hardware upgrade. 8 cores / 32 GB was sufficient once workers were sized right. More cores would have masked the bug, not fixed it.
The cheap mistakes that come up every audit
php-fpm-status reports configured limits, not actual RSS — measure with ps -o rss -C php-fpm on a warm box. Magento defaults to no Redis compression; enabling compress_data => 1 with compression_lib => 'zstd' shrinks the keyspace by 60–70%. And if your Varnish hit rate is zero on a public PDP, the answer is always Set-Cookie in the response — find the block that started the session and convert it to ESI.
FAQ
What is a good TTFB target for Magento 2 in 2026?
Google's "Good" threshold for field TTFB is 800 ms at p75. A properly tuned Varnish + Redis stack should sit at 150–250 ms on cached HTML and 400–600 ms on uncacheable routes like checkout. Above 1 s points at one of the three issues in this post — pool sizing, Redis namespace collisions, or Varnish bypass.
Does enabling Varnish always lower TTFB?
Only if Varnish actually caches. The most common reason a Magento store has Varnish installed but a slow TTFB is that the response carries Set-Cookie or Cache-Control: private and Varnish is forced to pass through. varnishlog -g request -q 'RespHeader ~ "X-Magento-Cache"' tells you in 30 seconds whether you are actually hitting cache.
How do I know if my PHP-FPM pool is sized correctly?
Run ps --no-headers -o rss -C php-fpm | awk '{sum+=$1; n++} END {print int(sum/n/1024)}' on a warm production box. Multiply the average RSS by pm.max_children and compare with free -m available memory. If the product exceeds available memory, the kernel will swap or kill workers under load.
Why is my Redis hit rate 94% but TTFB still slow?
Hit rate is computed across the full keyspace. If a neighbouring Magento installation shares the same Redis instance without distinct cache_id_prefix values, the other store's cache-clean events look like normal eviction in aggregate. Use redis-cli MONITOR for two minutes and watch the actual DEL traffic — collisions become obvious.
Should I use Varnish or Magento built-in FPC?
Use Varnish in any production environment. Magento's built-in FPC stores cache in the database or Redis as binary blobs — fast, but it shares pool capacity with the application. Varnish serves cached HTML in C with zero PHP involvement, which is what gets TTFB into the 150–250 ms range.
Does Hyvä change any of this?
Hyvä changes nothing about the server-side TTFB story. The PHP-FPM pool, Redis cache, and Varnish layer are identical to a Luma store. All three fixes apply to Luma 2.4.4 — 2.4.9 and Hyvä 1.2 — 1.3 stores equally.
What single command tells me which of the three issues I have?
Start with tail -200 /var/log/php-fpm.log | grep -E 'WARNING|SIGKILL'. If you see pm.max_children warnings or SIGKILL workers, that is Fix 1. If not, run redis-cli MONITOR | head -200 and watch for unexpected DEL traffic — Fix 2. If both come up clean, varnishlog -g request on a PDP showing MISS with Set-Cookie is Fix 3.
Related reading
References
- Production engagement traces from kishansavaliya.com Magento 2 performance audits, January — May 2026. CrUX field data via Google Search Console, RUM data via internal collector.
- Google Chrome team, Time to First Byte (TTFB), web.dev/articles/ttfb. "Good" threshold defined as ≤ 800 ms at p75.
- Adobe Commerce documentation, Configure PHP for Magento — recommended memory_limit and OPcache settings for 2.4.x.
- Varnish Cache project, VCL — Varnish Configuration Language and Magento bundled VCL generated via
bin/magento varnish:vcl:generate. - Redis Labs, Redis MONITOR command — debugging shared-instance namespace collisions.
I am Kishan Savaliya, an Adobe-Certified Magento + Hyvä developer. I run fixed-scope Magento 2 TTFB engagements with baseline curl + CrUX capture, the diagnostic order above, and a 30-day post-deploy monitoring window. Fixed quote from $499 audit · $2,499 sprint · ~28h @ $25/hr. See Magento 2 performance optimization or hire me.