TTFB WordPress

Fix TTFB in WordPress

Time to First Byte is the time from the browser opening a connection to the moment it receives the first byte of HTML. In WordPress the dominant cost is almost always uncached PHP execution: every request loads the entire WordPress core, fires plugins, runs the active theme, queries MySQL, renders the template, and only then sends a byte. With the right combination of full-page caching, object cache, opcache, and a CDN that caches HTML at the edge, a typical WordPress page can drop from 900 ms TTFB to under 150 ms without changing a single template file. This guide walks through the five settings that produce the largest gains.

Expected results

Before

920ms

TTFB (Poor) -- uncached PHP, no object cache, shared host, no CDN in front of HTML

After

145ms

TTFB (Good) -- full-page cache, Redis object cache, opcache tuned, CDN cache-everything for anonymous HTML

Step-by-step fix

Enable full-page caching with a proven plugin

The single biggest WordPress TTFB win comes from serving cached HTML from disk so the request never enters PHP. Use WP Rocket, W3 Total Cache, or LiteSpeed Cache (if you are on a LiteSpeed server). On a managed host such as Kinsta, WP Engine, Pressable, or Cloudways, prefer the host's built-in server-level cache because it intercepts requests at Nginx or LiteSpeed before PHP starts. Configure the cache to bypass logged-in users, WooCommerce cart and checkout pages, and any path with query parameters that should stay dynamic.

wp-config.php / .htaccess -- enable cache + bypass rules
// wp-config.php -- enable the cache constant required by most plugins
define('WP_CACHE', true);

// Bypass paths that must always be dynamic
// (WP Rocket / W3TC settings panel exposes the same options)
// - /wp-admin/
// - /cart/, /checkout/, /my-account/   (WooCommerce)
// - /?add-to-cart=
// - any URL with a logged-in auth cookie

// .htaccess -- serve a cached static file when present, skip PHP entirely
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_METHOD} !POST
  RewriteCond %{QUERY_STRING} ^$
  RewriteCond %{HTTP_COOKIE} !(wordpress_logged_in|comment_author|woocommerce_items_in_cart) [NC]
  RewriteCond %{DOCUMENT_ROOT}/wp-content/cache/wp-rocket/%{HTTP_HOST}%{REQUEST_URI}/index.html -f
  RewriteRule .* /wp-content/cache/wp-rocket/%{HTTP_HOST}%{REQUEST_URI}/index.html [L]
</IfModule>

// Verify a cache hit on a logged-out request:
// $ curl -sI https://example.com/ | grep -iE 'x-cache|x-rocket'
// expected: x-cache: HIT  (or x-rocket: enabled)

Add a persistent object cache backed by Redis

WordPress stores menus, options, term metadata, and transients in an in-memory object cache during each request. By default that cache is thrown away at the end of the request. A persistent backend such as Redis or Memcached keeps the cache between requests so the next page load reuses the same objects instead of re-querying MySQL. The official Redis Object Cache plugin (by Till Kruss) is the most widely used implementation. Install Redis (or use the host's managed Redis), drop the plugin in, then enable the drop-in from the plugin settings page.

wp-config.php -- Redis Object Cache configuration
// wp-config.php -- placed above the "stop editing" line

// Connection details
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
// Optional auth + DB index
// define('WP_REDIS_PASSWORD', 'your-password');
define('WP_REDIS_DATABASE', 0);

// Use a unique salt per site so multisite installs do not collide
define('WP_CACHE_KEY_SALT', 'example_com_');

// Enable the persistent cache
define('WP_REDIS_DISABLED', false);

// Recommended for production
define('WP_REDIS_TIMEOUT', 1);
define('WP_REDIS_READ_TIMEOUT', 1);

// After activating Redis Object Cache plugin, click "Enable Object Cache"
// in Settings -> Redis. Confirm with:
// $ wp redis status
// Status:  Connected
// Client:  PhpRedis (5.x)
// Drop-in: Valid

Tune PHP opcache so source files are parsed once

PHP opcache stores precompiled script bytecode in shared memory so subsequent requests reuse it instead of re-parsing and re-compiling every PHP file. On a default install the cache is enabled but undersized, so WordPress + plugins evict entries on every memory pressure event. Increase memory to 256 MB or more, raise the maximum accelerated files to cover the full WordPress codebase including plugins, and disable timestamp validation in production so opcache trusts the cached entry until you explicitly reset it after deploys.

php.ini -- opcache production settings
; /etc/php/8.2/fpm/php.ini -- or your host's PHP-FPM config

[opcache]
opcache.enable=1
opcache.enable_cli=0

; Shared memory available to opcache. 256 MB fits most WordPress sites
; with 30+ plugins. Increase if "out of memory" appears in PHP logs.
opcache.memory_consumption=256

; Strings interned across requests. 16 MB is a safe default.
opcache.interned_strings_buffer=16

; Maximum files cached. Run `find . -name '*.php' | wc -l` in wp-content
; to size this -- include core (~2000) + plugins + themes.
opcache.max_accelerated_files=20000

; In production: do NOT recheck file timestamps on every request.
; Reset opcache manually on deploy with `opcache_reset()` or fpm reload.
opcache.validate_timestamps=0

; Save compiled bytecode to disk across PHP-FPM restarts
opcache.file_cache=/var/cache/php-opcache

; JIT: PHP 8.x improves throughput on uncached requests
opcache.jit=1255
opcache.jit_buffer_size=128M

; Then reload PHP-FPM:
; $ sudo systemctl reload php8.2-fpm

Put a CDN in front of the origin and cache HTML at the edge

A standard WordPress + CDN setup only caches static assets (CSS, JS, images). HTML still travels from the origin on every page view, which leaves TTFB tied to origin distance for every user. Configure the CDN to cache HTML responses for anonymous visitors so the document itself is served from the nearest edge node. On Cloudflare this is a single page-rule or a Cache Rule that sets Cache-Control: public, s-maxage=N and bypasses cache when a WordPress auth cookie is present.

Cloudflare Cache Rule -- cache HTML for anonymous visitors
# Cloudflare dashboard -> Caching -> Cache Rules
# (Equivalent settings exist for Bunny CDN, KeyCDN, Fastly, and Vercel)

# Rule name: WordPress HTML at edge
# When incoming requests match:
#   (http.host eq "example.com" and http.request.uri.path matches "^/(?!wp-admin|wp-login|cart|checkout|my-account).*")
#   and not (http.cookie contains "wordpress_logged_in")
#   and not (http.cookie contains "wp-postpass")
#   and not (http.cookie contains "woocommerce_items_in_cart")
# Then:
#   Cache eligibility: Eligible for cache
#   Edge TTL: Override origin -> 1 hour
#   Browser TTL: Respect origin
#   Cache key: include scheme + host + URI + device-type

# Add an origin header to confirm at the edge
# WP filter: send Cache-Control + Surrogate-Control
add_filter('wp_headers', function ($headers) {
  if (!is_user_logged_in() && !is_admin()) {
    $headers['Cache-Control']     = 'public, max-age=0, s-maxage=3600';
    $headers['Surrogate-Control'] = 'max-age=3600';
  }
  return $headers;
});

# Verify cache hit:
# $ curl -sI https://example.com/ | grep -iE 'cf-cache-status|age'
# expected: cf-cache-status: HIT

Enable HTTP/2, HTTP/3, and Brotli at the host level

HTTP/2 and HTTP/3 multiplex multiple asset requests over a single connection, removing the head-of-line blocking that inflates TTFB on asset-heavy WordPress themes. Brotli compresses HTML, CSS, and JS roughly 15--25% more efficiently than gzip, which directly cuts the time to transfer the HTML document on the first paint path. Modern managed hosts and CDNs enable both automatically; for self-hosted Nginx or Apache, switch them on explicitly and verify with curl.

Nginx + Apache -- HTTP/2 and Brotli
# Nginx -- enable HTTP/2 and Brotli (requires nginx-module-brotli)
server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # Add HTTP/3 if your Nginx build supports QUIC
  # listen 443 quic reuseport;
  # add_header Alt-Svc 'h3=":443"; ma=86400';

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

  gzip                on;
  gzip_types          text/html text/css application/javascript application/json image/svg+xml;
  gzip_min_length     256;
}

# Apache 2.4 -- enable HTTP/2 + Brotli
# In httpd.conf or a vhost include:
Protocols h2 h2c http/1.1
LoadModule brotli_module modules/mod_brotli.so
<IfModule mod_brotli.c>
  AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
</IfModule>

# Verify in DevTools (Network -> document request -> Headers):
# - HTTP/2 200      (protocol)
# - content-encoding: br  (Brotli active)

Quick checklist

  • Full-page caching plugin or host-level cache serves anonymous HTML from disk
  • Redis or Memcached object cache is enabled and reporting wp redis status: Connected
  • PHP opcache is sized for the codebase (opcache.memory_consumption=256, max_accelerated_files=20000)
  • CDN page-rule caches HTML for logged-out visitors and bypasses on auth cookies
  • Site is running PHP 8.2 or newer with JIT enabled
  • HTTP/2 (or HTTP/3) and Brotli are confirmed at the edge via curl -I

Frequently asked questions

The most common causes are uncached PHP execution (every request rebuilds the page from the database), missing or misconfigured object cache (so menu, options, and term queries hit MySQL on every request), shared hosting with no PHP opcache or low PHP-FPM worker counts, no CDN in front of the origin (so users far from the datacenter incur full round-trip latency), and bloated plugins that add synchronous external API calls during page render. Address them in that order.

It eliminates most of the cost for repeat anonymous visits because the rendered HTML is served from disk. It does not help logged-in users, the first request after cache invalidation, or pages with personalized content. Pair full-page caching with a persistent object cache (Redis or Memcached) and a CDN that caches HTML at the edge so the origin is rarely touched even after invalidation.

Google's Core Web Vitals thresholds rate TTFB as Good under 800 ms, Needs Improvement between 800 and 1,800 ms, and Poor above 1,800 ms. For WordPress with a warm cache and a CDN, realistic targets are under 200 ms for HTML served from the edge and under 600 ms for cache-miss responses generated at the origin. If your cache-hit TTFB is over 400 ms you usually have a CDN configuration problem rather than a WordPress one.

The single most important variable is whether the plugin actually serves cached HTML from disk (or from a CDN edge) without booting WordPress. WP Rocket, LiteSpeed Cache (on LiteSpeed servers), and W3 Total Cache all do this when configured correctly. Many managed hosts (Kinsta, WP Engine, Pressable, Cloudways) ship a server-level cache that is faster than any plugin because it intercepts the request at Nginx or LiteSpeed before PHP starts. If you are on a managed host, use the host's cache first.

Use WebPageTest for synthetic measurements -- it breaks TTFB into DNS, TCP, TLS, and server-processing components so you can see whether the issue is network or application. In Chrome DevTools, open the Network tab, reload the page, and read TTFB from the document request's Timing tab. For field data, install the web-vitals JS library and report onTTFB to your analytics. To distinguish cache hits from cache misses, check response headers for x-cache, cf-cache-status, or x-litespeed-cache, and run the test multiple times to compare.

Yes, materially. Moving from PHP 7.4 to PHP 8.2 or newer typically improves WordPress request throughput by 15 to 30 percent on cache-miss requests because of JIT compilation and core engine improvements. The gain is largest for sites with heavy WooCommerce, ACF, or page-builder workloads. Most managed hosts let you switch PHP version in the dashboard; test on a staging site first because a few legacy plugins still require PHP 7.x.

Continue learning