Skip to content

PerformanceUpdated 7 min read

How to reduce WordPress TTFB: a diagnostic guide

Reduce WordPress TTFB by separating CDN, network, PHP-FPM, database, cache, and WordPress delays with repeatable diagnostic tests.

Portrait of Hamza Ahmad AslamBy AI & WordPress Developer | Speed Optimization & Site Security

A relay of messengers runs glowing parcels from a distant castle to a doorstep and stopwatch, while miners dig a shortcut tunnel below.
Illustration generated with ChatGPT
On this page
  1. How to reduce WordPress TTFB with repeatable tests
  2. Separate the request phases with curl
  3. Compare cached and uncached requests
  4. How to tell edge time from origin time
  5. Read CDN cache status headers
  6. Test the origin directly when access allows it
  7. How to expose server time with Server-Timing
  8. How to profile WordPress after the request reaches PHP
  9. How to confirm the usual causes before fixing them
  10. Missing or bypassed page cache
  11. Slow database queries
  12. A large autoloaded options set
  13. No persistent object cache
  14. Slow external API calls on the request path
  15. Saturated PHP-FPM workers
  16. OPcache disabled or undersized
  17. Redirect chains
  18. How to verify each change
  19. What to do next
  20. Frequently asked questions

Short answer: To reduce WordPress TTFB, measure the same request at each layer instead of tuning WordPress blindly. Separate DNS, connection and TLS time from time to first byte, compare CDN hits with origin requests, then profile PHP, database work and external calls on the slow path.

How to reduce WordPress TTFB with repeatable tests

TTFB is elapsed time from navigation start until the first response byte arrives. The web.dev TTFB guide (opens in a new tab) includes redirects, DNS, connection setup, TLS, the request, server processing and the trip back to the client. Its current rough guide is that most sites should strive for 0.8 seconds or less.

That is a user-facing guideline, not a PHP budget. Server response time is only one part of the measured path, and a fast edge cache can hide a slow origin.

Separate the request phases with curl

Start with one URL, one test location and one cache state. This command discards the body and prints cumulative curl timings:

curl -sS -o /dev/null \
  -w 'DNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n' \
  https://example.com/

Example output, with illustrative values only:

DNS: 0.018421s
Connect: 0.052884s
TLS: 0.091736s
TTFB: 0.284913s
Total: 0.329442s

The values are cumulative from the start of the transfer. time_namelookup ends after name resolution. time_connect ends after the transport connection. time_appconnect ends after the TLS handshake for HTTPS. time_starttransfer ends when curl receives the first byte.

Subtract adjacent values for an approximate phase cost. The gap from TLS completion to first byte still includes request transmission, edge or origin processing, and return transit. It is not pure PHP time.

Repeat the same command several times. Keep individual runs so intermittent queueing remains visible.

Compare cached and uncached requests

Warm an anonymous public URL, then rerun the same request and inspect its cache headers. For an origin-path comparison, use a logged-in test session when that state normally bypasses full-page cache:

curl -sS -b cookies.txt -o /dev/null \
  -w 'DNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n' \
  https://example.com/

You can also try a query string such as ?ttfb_probe=1, but do not assume it bypasses cache. CDN and page-cache rules can include, ignore or separately cache query strings. Verify the result from the response headers.

Keep public and personalized requests separate. Anonymous and logged-in WooCommerce requests can take different execution paths.

How to tell edge time from origin time

A CDN hit measures the edge path. A miss or bypass usually includes the origin path too. Compare both before changing PHP or database settings.

Read CDN cache status headers

Dump only the response headers first:

curl -sS -D - -o /dev/null https://example.com/

Cloudflare uses cf-cache-status. HIT means Cloudflare served its cache. MISS means it fetched from origin. BYPASS and DYNAMIC are not cache hits. Other CDNs and proxies may expose x-cache style headers.

Age reports how long a response has been stored in a proxy cache. Record the provider-specific CDN cache status beside every timing run.

Example output, with illustrative values only:

HTTP/2 200
cf-cache-status: HIT
age: 84

If the public page is slow only on a miss, focus on the origin path. If a confirmed edge hit is still slow, inspect client-to-edge DNS, connection, TLS and routing before touching WordPress.

Test the origin directly when access allows it

If the origin accepts direct traffic for the production hostname, curl can pin that hostname to the origin IP while preserving the URL host and TLS name:

curl -sS --resolve example.com:443:ORIGIN_IP -o /dev/null \
  -w 'TTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n' \
  https://example.com/

Replace ORIGIN_IP with the actual origin address. Do not disable certificate validation to make this test pass. If the origin firewall accepts only CDN traffic, run an equivalent test from an authorized network or use host-level monitoring.

Compare a CDN hit, miss and direct-origin request with the same application state. The gap points to the layer that needs the next trace.

How to expose server time with Server-Timing

The Server-Timing response header (opens in a new tab) lets browser DevTools display server-side metrics beside the network request. A small must-use plugin can mark WordPress bootstrap stages without modifying the theme.

Create wp-content/mu-plugins/ttfb-server-timing.php:

<?php
/**
 * Plugin Name: TTFB Server Timing
 */

global $ha_ttfb_start, $ha_ttfb_marks;

$ha_ttfb_start = isset( $_SERVER['REQUEST_TIME_FLOAT'] )
    ? (float) $_SERVER['REQUEST_TIME_FLOAT']
    : microtime( true );

$ha_ttfb_marks = array(
    'bootstrap' => ( microtime( true ) - $ha_ttfb_start ) * 1000,
);

function ha_ttfb_mark( $name ) {
    global $ha_ttfb_start, $ha_ttfb_marks;

    $ha_ttfb_marks[ $name ] = ( microtime( true ) - $ha_ttfb_start ) * 1000;
}

add_action( 'plugins_loaded', static function () {
    ha_ttfb_mark( 'plugins' );
}, 999 );

add_action( 'after_setup_theme', static function () {
    ha_ttfb_mark( 'theme' );
}, 999 );

add_action( 'init', static function () {
    ha_ttfb_mark( 'init' );
}, 999 );

add_action( 'wp_loaded', static function () {
    ha_ttfb_mark( 'wp_loaded' );
}, 999 );

add_filter( 'wp_headers', static function ( $headers ) {
    global $ha_ttfb_start, $ha_ttfb_marks;

    if ( ! current_user_can( 'manage_options' ) ) {
        return $headers;
    }

    $previous = 0.0;
    $metrics  = array();

    foreach ( $ha_ttfb_marks as $name => $cumulative ) {
        $metrics[] = sprintf( '%s;dur=%.1f', $name, $cumulative - $previous );
        $previous  = $cumulative;
    }

    $to_headers = ( microtime( true ) - $ha_ttfb_start ) * 1000;
    $metrics[]  = sprintf( 'to_headers;dur=%.1f', $to_headers - $previous );

    $headers['Server-Timing'] = implode( ', ', $metrics );

    return $headers;
}, 999 );

The bootstrap mark covers what ran before must-use plugins loaded, including wp-config.php, the database connection and any object cache drop-in. The code emits the header only for users who can manage_options, limiting exposure of internal timing data. DevTools will show the metrics on that authenticated request.

These marks stop when WordPress prepares its main headers. They do not measure PHP work after that point. They also do not include time a request spent waiting for a free PHP-FPM worker. Pair them with Query Monitor and PHP-FPM diagnostics.

How to profile WordPress after the request reaches PHP

Install Query Monitor (opens in a new tab) on a safe test or controlled production session. Its official plugin documentation covers database queries, PHP errors, hooks and actions, HTTP API calls, and grouping database queries by the responsible plugin, theme or function.

Start on the exact slow URL and user state. Check database time, slow or repeated queries and their callers. Review HTTP API calls that block the response, then use hook and component data to tie work to code.

Disable the profiler for final curl comparisons because profiling itself adds work.

How to confirm the usual causes before fixing them

Missing or bypassed page cache

Confirm page caching with response headers, not a settings screen. Repeated misses on an eligible public URL need investigation.

Fix the cache key, eligibility rule, purge behavior or page-cache layer identified by the trace. Do not force public HTML caching onto personalized account, cart or checkout responses.

Slow database queries

Use Query Monitor to confirm database time on the slow dynamic request and identify each expensive query's caller. Remove repeated lookups or unnecessary metadata scans first. Add an index only when the actual query and its execution plan justify it.

A large autoloaded options set

Since WordPress 6.6, Site Health includes an autoloaded-options size test. The current site_status_autoloaded_options_size_limit reference (opens in a new tab) documents a default warning threshold of 800,000 bytes.

Confirm ownership before changing an option. Remove only verified stale data, and stop autoloading large values not needed on most requests.

No persistent object cache

WordPress's built-in object cache is non-persistent, so cached objects normally disappear after each request. If repeated dynamic traces show the same cacheable lookups, add a supported persistent object-cache backend and measure database time again. Page cache and object cache solve different problems.

Slow external API calls on the request path

Query Monitor exposes WordPress HTTP API calls and timing. If a remote call blocks the pre-header path, cache stable remote data when allowed or move nonessential work outside the user-facing request. For required calls, define timeout and failure behavior deliberately.

Saturated PHP-FPM workers

pm.max_children limits the number of simultaneous requests a PHP-FPM pool can serve. The PHP-FPM status page (opens in a new tab) exposes the listen queue, active and idle processes, max children reached, and slow-request counters.

Enable a status path in the relevant pool:

pm.status_path = /fpm-status

Map that path to the FPM socket and restrict it. The PHP manual shows this Apache pattern:

<LocationMatch "/fpm-status">
    Require local
    ProxyPass "unix:/var/run/php-fpm.sock|fcgi://localhost/"
</LocationMatch>

The socket path must match your deployment. Then read the status locally:

curl -sS 'http://127.0.0.1/fpm-status?full'

Example output, with illustrative values only:

pool:                 www
process manager:      dynamic
listen queue:         2
idle processes:       0
active processes:     8
total processes:      8
max active processes: 8
max children reached: 3
slow requests:        4

A nonzero listen queue during the incident means requests are waiting. A rising max children reached counter confirms the pool has hit its worker limit since startup. If saturation blocks the normal status URI, PHP also documents pm.status_listen for a separate status listener.

Enable the FPM slow log in the same pool with a threshold chosen for the request you are investigating:

request_slowlog_timeout = 1s
slowlog = /var/log/php-fpm/www-slow.log

PHP documents 0 as the default for request_slowlog_timeout, which disables slow-request tracing. The 1s value above is an illustrative capture threshold, not a universal target. Use the emitted PHP backtraces to find the blocked code path before raising pm.max_children.

More workers consume more memory, CPU and database capacity. Raising the limit without checking those resources can move the queue elsewhere. Use the guide to sizing WordPress PHP workers from memory per process before changing worker counts.

OPcache disabled or undersized

Check OPcache from the same FPM SAPI that serves WordPress. A CLI php -i result can describe a different SAPI and configuration file, so it is not enough to prove what FPM is using.

A protected temporary diagnostic using opcache_get_status(false) can show whether OPcache is enabled, cache fullness, memory use and restart counters. Remove it after diagnosis. If the cache fills or restarts for lack of space, size it from observed use instead of copying a fixed value.

Redirect chains

Redirects add to navigation TTFB before the final response starts. Follow the chain and print every response header block:

curl -sS -L -D - -o /dev/null https://example.com/

Point internal links at the canonical HTTPS URL and consolidate avoidable redirect hops. Recheck login, checkout, language and canonical-host behavior afterward.

How to verify each change

Change one layer at a time. Run the same curl timing, from the same test location, against the same URL and user state. Record the cache status with the timing so a CDN hit is never compared casually with an origin miss.

For application changes, compare the same Query Monitor view or Server-Timing markers. For PHP-FPM, compare queue and worker counters under equivalent load.

Retest the real browser path. TTFB is not a Core Web Vital, but slow TTFB can delay later loading milestones.

What to do next

If dynamic requests still repeat database reads, follow the Redis object cache guide. If Site Health flags autoloaded data, use the autoloaded options guide before changing rows.

Once TTFB is stable, set broader page targets with the free Core Web Vitals budget calculator. If the bottleneck spans CDN, PHP-FPM and database layers, the WordPress performance optimization service covers diagnosis, implementation and verification.

Frequently asked questions

What is a good WordPress TTFB?

web.dev says most sites should strive for a TTFB of 0.8 seconds or less. Treat that as a rough user-facing guide, then split the result into network, edge and origin time before deciding what to fix.

Why is WordPress TTFB fast when cached but slow when logged in?

A full-page cache can serve anonymous HTML without running the normal WordPress request path. Logged-in traffic often reaches PHP, database queries and plugin code, so the difference points you toward origin-side profiling rather than CDN tuning alone.

Can a CDN fix a slow WordPress TTFB?

A CDN can reduce TTFB for responses it can serve from an edge cache and can shorten network distance to visitors. It does not remove slow PHP, database or remote API work on cache misses and dynamic requests.

Does Redis always reduce WordPress TTFB?

No. A persistent object cache helps when repeated dynamic requests spend time rebuilding cacheable data or repeating database reads. Confirm that pattern first, then compare the same uncached request before and after enabling Redis.

How this article was made: drafted with AI assistance from a researched brief, then checked against primary documentation before publishing.

Enjoyed this? Get the next article by email.

Occasional, useful posts. No spam, and you can unsubscribe anytime.

  • A faceless mannequin chef takes a ready meal box from warm, lit pantry shelves instead of cooking, with a shelf-space gauge on the side.Performance

    7 min read

    WordPress OPcache settings: size it, check it and keep it warm

    WordPress OPcache settings should be sized from the cache your PHP-FPM workers are using, not from a copied tuning block. Check the live OPcache status, count the PHP files in the active release, then make deployment resets and warm-up part of the release process.

    • WordPress
    • PHP
    • Performance
    Read article
  • Four faceless mannequin cooks at fixed stations while a long rail of order tickets backs up along the glowing pass.Performance

    8 min read

    WordPress PHP workers: size them, spot saturation and use fewer

    WordPress PHP workers are PHP-FPM child processes that execute uncached PHP requests, and pm.max_children caps how many a pool can serve at once. Size the pool from measured worker memory and available RAM, then confirm saturation with the PHP-FPM status page and logs before adding workers.

    • WordPress
    • PHP
    • Performance
    Read article
  • A robot librarian scans endless shelves book by book while a glowing card-index drawer sits unused beside a plan of the faster path.Performance

    8 min read

    How to find and fix slow WordPress database queries

    WordPress slow database queries are usually found by measuring the actual SQL, its execution time, and how often it runs before changing indexes or code.

    • WordPress
    • Database
    • MySQL
    Read article