September 2026 · ~10 min read · TTFB, nginx cache, Apache, WordPress
Two layers. The origin's headers decide what the nginx cache in front is allowed to do, which is the whole story.
A client's WordPress site had slow, highly variable Time To First Byte, and an SEO audit had flagged it as "HTML not edge cached, multi-second and inconsistent first byte." The claim was partly true, partly a measurement artifact, and the real fix was more subtle than the audit suggested. I had no DNS or CDN access, so the textbook answer was off the table. What I did have was the origin, a careful set of measurements, and one observation that reframed the entire problem.
The stack I was working in
Naming it matters, because the fix lives in how these pieces talk to each other. WordPress with Elementor Pro, cached by WP Rocket, with Gravity Forms, ACF Pro, Wordfence and Profile Builder in the render path. The web server was Apache driven by .htaccess, and in front of it sat an nginx reverse-proxy cache (an InMotion-style cPanel setup where Server: nginx fronts an Apache origin). The runtime was PHP 8.1 with Zend OPcache. My instruments were curl for header and timing analysis and Query Monitor for server-side profiling. The key realisation, early, was that this is a two-layer architecture: the cache headers the Apache origin sends decide what the nginx layer is allowed to do.
Measure before you prescribe
The audit quoted a dramatic spread, roughly 350 ms up to 5,000 ms, plus headers showing Cache-Control: max-age=0 and an Expires pinned to now. Before accepting or rejecting that, I reproduced it with repeated curl requests, capturing the full timing breakdown (DNS, connect, TLS, TTFB, total) and the headers on every load. TTFB actually sat consistently in the 2.1 to 4.3 s range, and the proxy status alternated between MISS and HIT. Then the decisive observation:
A cache HIT was no faster than a cache MISS. Both sat around 2.5 seconds.
That single fact pointed straight at the cause. A working HIT should serve in tens of milliseconds of server time. A HIT that costs the same as a MISS means the cache is storing the page but is forced to revalidate against the slow origin on every request, which is exactly what Cache-Control: max-age=0 plus an expired Expires instructs a shared cache to do. The cache was running and doing nothing useful.
Separating network distance from real server time
The raw numbers were inflated by my own distance from the server, so I reused a single connection across requests to pay the TLS handshake and setup only once and isolate the server's true think time.
On a warm connection the real server time separates cleanly: a served hit is a fraction of a second; the cold render is the platform floor.
On a warm connection a cache HIT came in around 0.34 to 0.70 s while a real cache MISS (an actual PHP render) took 2.1 to 3.4 s. This is why "the site is slow" and "the server is slow" are not the same claim: on a cold connection from a distant location you pay DNS, TCP, TLS and several round trips before the first byte, and that alone adds well over a second. Measuring carefully stopped me from chasing network distance as if it were a server defect.
Reading the actual configuration
The site served Server: nginx yet used .htaccess, which only Apache reads. That confirmed the two layers. Reading the live .htaccess, the origin caching rules were generated by WP Rocket and included an HTML5-Boilerplate block with this line:
ExpiresByType text/html "access plus 0 seconds"
That, combined with WordPress sending its own no-cache headers for dynamic pages, is what forced max-age=0 and an immediate Expires on every HTML response, including the static cache files WP Rocket produced. The nginx layer, obeying those headers, revalidated the origin on every hit.
The core fix: make anonymous HTML cacheable at the proxy
The goal was to get nginx to serve cached HTML without revalidating the slow origin, while never caching logged-in, admin, cart or private pages. My first attempt targeted files by extension:
<FilesMatch "\.(html|htm)$">
Header set Cache-Control "public, max-age=600, s-maxage=3600"
</FilesMatch>
It did nothing for the pages that mattered, and the reason is instructive: WP Rocket on this setup serves pages through PHP (index.php), not as static .html files, so a match on the .html extension never applied to the homepage or the service pages. I knew the block itself was active because the security headers I set unconditionally did apply. The HTML rule simply never matched the real responses. Since I could not match by extension (PHP) or by content type (mod_headers cannot condition on Content-Type), I tagged the right requests with an environment flag via mod_rewrite, then applied the cache header on that flag:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} =GET
RewriteCond %{QUERY_STRING} =""
RewriteCond %{REQUEST_URI} !^/wp-admin [NC]
RewriteCond %{REQUEST_URI} !^/wp-login\.php [NC]
RewriteCond %{REQUEST_URI} !^/wp-json [NC]
RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|wp-postpass|woocommerce_items_in_cart) [NC]
RewriteRule ^ - [E=PROXY_CACHE:1]
</IfModule>
<IfModule mod_headers.c>
Header set Cache-Control "public, max-age=600, s-maxage=3600" "expr=reqenv('PROXY_CACHE') == '1' || reqenv('REDIRECT_PROXY_CACHE') == '1'"
Header unset Expires "expr=reqenv('PROXY_CACHE') == '1' || reqenv('REDIRECT_PROXY_CACHE') == '1'"
</IfModule>
The mechanism is worth spelling out, because two details are exactly where this pattern silently fails. The rewrite flags only anonymous, non-admin, no-query-string GET requests that carry no login or cart cookie. mod_headers then overrides the origin's max-age=0 for just those requests, and because mod_headers runs after mod_expires in Apache's order, it reliably wins without editing WP Rocket's own block. The expr checks both the flag and its REDIRECT_ prefixed form, because WordPress performs an internal rewrite to index.php and internal redirects rename environment variables with that prefix. Miss that, and the header never applies. One more decision: I placed this after the # END WP Rocket marker, not inside it, because WP Rocket regenerates its own block on every cache clear and would erase anything within it.
The result: the proxy now serves cached hits from cache, and warm HIT TTFB dropped to about 0.34 s. Anonymous pages carry public, max-age=600, s-maxage=3600; logged-in and admin requests correctly stay private. The honest limit built into it: it deliberately excludes query-string URLs, so the first request to a cold page and any tracking-tagged visit still reach the origin.
Three fixes I made in the same pass
With the cache fixed, I hardened the rest at the same layer. I added a full set of security headers (frame options, nosniff, referrer policy, a locked-down permissions policy, a frame-ancestors 'self' CSP and HSTS), and flagged one caution rather than treating it as fire-and-forget: HSTS with includeSubDomains; preload must not be submitted to the preload list until every subdomain is confirmed on HTTPS, or a non-HTTPS subdomain becomes unreachable. While auditing the file tree I also found a phpinfo.php that had been publicly readable for months, exposing absolute paths, modules and exact versions, ideal reconnaissance for an attacker. I confirmed its contents, took what I needed (OPcache was already correctly enabled), and had it removed immediately.
Diagnosing the render floor, and being honest about it
The database was never the problem. The cost is PHP, dominated by the page builder and plugin bootstrap on every uncached request.
The remaining question was the cache-miss render itself. I profiled a page with Query Monitor, and the split ruled out the obvious guess: total generation about 3.10 s, of which the database was only 0.25 s (about 8 percent) and PHP execution was about 2.85 s (about 92 percent), dominated by Elementor. So I acted on the evidence rather than the assumption. OPcache was already on and well sized, so I left it. A persistent object cache (Redis) was not offered on this plan, and with database time at only 0.25 s it would have helped little anyway, so I deactivated the object-cache plugin that could not connect rather than leave it erroring. I tuned Elementor (performance experiments, external CSS, element caching, regenerated CSS and data) and confirmed WP Rocket's CSS and JS optimisations were on.
The honest outcome: the cache-miss render stayed around 3 s, and element caching produced no measurable gain, because that time is mostly framework and plugin bootstrap on every uncached request, and element caching cannot persist between requests without a persistent object cache this host does not provide. That is the practical floor for this plugin stack on this hosting tier. The correct engineering answer is not to keep fighting the render, it is to make sure real traffic rarely hits it, which the caching layer now achieves, and to hand two items to the host: enable HTTP/2 (the site was negotiating HTTP/1.1), and confirm whether Redis or Memcached can be enabled so object and element caching can actually persist.
What changed
| Metric | Before | After |
|---|---|---|
| Cache HIT behaviour | HIT no faster than MISS (revalidated origin every hit) | Served from cache |
| Warm cache HIT TTFB | ~2.5 s equivalent | ~0.34 s |
| HTML cache headers | max-age=0, Expires now | public, max-age=600, s-maxage=3600 |
| Security headers | none | full set (frame, nosniff, referrer, permissions, CSP, HSTS) |
| Public server-info exposure | phpinfo.php readable for months | removed |
| Render diagnosis | unknown | isolated to page-builder PHP, 92% PHP vs 8% DB |
Who benefits: anonymous visitors and search-engine crawlers, the large majority of traffic, now get cached, fast responses. Only the first visitor after a cache window expires, and traffic carrying tracking parameters, still reaches the slower origin render.
A few hurdles worth knowing
- No DNS or CDN access. The textbook fix for slow, distant TTFB is a CDN, and every CDN needs a DNS change I could not make. I stopped trying to route around it and solved it at the origin instead.
- The server banner lied. It said nginx, so my first instinct was an nginx config. The
.htaccessproved Apache was the real origin behind an nginx cache, which changed the whole approach from "edit nginx" to "control the headers the origin sends." - The first cache fix did nothing, and diagnosing why (security headers applied, the cache header did not, so the match was wrong) is what led to the rewrite-flag approach.
- The cache plugin overwrites its own config, so the override had to live after WP Rocket's markers and win on Apache's header ordering to survive regeneration.
- Element caching should have worked and did not. Rather than call it done, I traced the real limit: without a persistent object cache it cannot survive between requests, and the dominant cost is framework bootstrap. Naming the floor beats pretending the number moved.
What I'd tell another engineer
Measure before you prescribe. The audit's dramatic numbers were partly real and partly distance, and the real defect was hidden in a single header line and in the difference between a cache that stores and a cache that serves. The most valuable move in the whole job was noticing that a HIT was no faster than a MISS, because that one comparison pointed straight at revalidation. After that, the work was about understanding which layer owned which behaviour, and being honest about the floor the platform imposed rather than chasing a number the stack could not reach.