Guide

WordPress slow page load — diagnosing high TTFB when queries look fine

Chrome DevTools shows "Waiting for server response" at 5.8 seconds on a normal post page. You SSH in, run SHOW PROCESSLIST, and MySQL is idle — no long-running queries, no locks, no wppostmeta joins. The slow query log is empty.

1. Problem

Chrome DevTools shows "Waiting for server response" at 5.8 seconds on a normal post page. You SSH in, run SHOW PROCESSLIST, and MySQL is idle — no long-running queries, no locks, no wp_postmeta joins. The slow query log is empty. New Relic points at "PHP" and stops being helpful. Your search history reads "wordpress pages loading slowly after plugin update", "wordpress slow ttfb diagnose", "wordpress slow page load not database", and every result tells you to install a caching plugin. You already have one. The pages are still slow.

This is the failure mode where the database is innocent. Queries are fast, autoload is small, the object cache is warm — and TTFB is still 5+ seconds because something inside the PHP request is blocking. A hook callback is making a synchronous outbound HTTP call. OPcache is missing and recompiling 800 files per request. The object cache is misconfigured and silently falling back to MySQL. FPM workers are stacked up waiting on each other. wp_slow_requests_total keeps climbing while every dashboard says fine.

This guide uses wp_slow_requests_total (incremented by the rule wp_slow_page_load when an http.request exceeds 5000ms execution time) as the entry point, then walks down through wp_request_peak_memory_mb, wp_request_fingerprint_top, and perf.hook_timing to find the non-database cause.

2. Impact

A 6-second TTFB is not a soft problem. Google's crawler scores it as a "poor" Core Web Vital on first hit. Real users abandon at 3 seconds; on mobile, 1.5. On e-commerce, the slowness sits on the pages that bypass the page cache — product detail, checkout review, account dashboard. The cached homepage is fast. The pages that earn money are not.

Operationally, the failure cascades. WordPress runs on PHP-FPM with a fixed worker pool. When each request takes 5 seconds instead of 200ms, you've cut concurrency by 25x. A site that served 200 req/sec now stalls at 8. The upstream queue fills, requests queue at the load balancer, and you start returning 502 Bad Gateway — with no PHP fatal, no database error, no signal in the hosting dashboard. Production looks fine. The site is down for new visitors.

The most painful version is the one that correlates with a recent plugin update. The team rolls back the plugin. The slowness stays. The update was the trigger, not the cause — the underlying issue (missing object cache, FPM worker shortage, OPcache resetting every request) was already there.

3. Why It’s Hard to Spot

WordPress doesn't surface PHP-side latency anywhere visible. Site Health reports nothing. wp_footer doesn't print timing. Query Monitor is disabled in production. The hosting dashboard graphs CPU, memory, and bandwidth — none of which spike during this failure, because the worker is blocked, not busy. Outbound HTTP calls and lock waits don't show up as CPU.

Synthetic uptime checks miss it. Pingdom hits / every 60 seconds, gets a 200ms cached HTML, and reports the site as healthy. The actual victims — logged-in users, customers in checkout, the REST API consumed by the mobile app — never touch the page cache. They are served by PHP-FPM, and they are the ones waiting 6 seconds.

Plugin updates make the diagnosis worse. A plugin update that adds one wp_remote_get to init introduces a fixed 800ms delay on every uncached request. The site doesn't break — it just gets slower. The team blames "the plugin update last Tuesday" but can't prove it. Without perf.hook_timing per-callback data, it becomes a guessing game that ends with someone rebuilding the page cache more aggressively — masking the symptom, not fixing it.

The deepest confusion: the failure can be FPM-pool-shaped, not request-shaped. If all 10 workers are blocked, request 11 waits for a worker. Its TTFB now includes that wait, even though the request itself does nothing slow. wp_slow_requests_total increments for request 11 while the real culprit is requests 1-10. Diagnosis requires fleet-wide signals, not single-request traces.

4. Cause

wp_slow_requests_total is a Prometheus counter incremented by the processor every time an http.request event arrives with execution_time_ms > 5000. The rule that drives it (wp_slow_page_load) fires on the same threshold and emits an alert when the rate sustains. In production, this metric has fired 606 times in the last 7 days across 2 entities, with 35,564 samples on 5 of 5 monitored sites — meaning slow requests are not a one-off; they're a persistent class of failure across the fleet.

What the signal represents internally: the WordPress plugin records microtime(true) at plugins_loaded and again at shutdown. The delta is execution_time_ms. When that exceeds 5 seconds, the plugin emits an http.request envelope tagged with the URL, method, response code, peak memory, and a request fingerprint. The processor matches it against the metric's conditions and increments the counter — bucketed by entity, route, and status.

This is the signal you watch when database signals are clean. Specifically:

  • If db.slow_query is also firing, you have a database-bound slow request — go to the slow-queries guide.
  • If db.slow_query is not firing but wp_slow_requests_total keeps incrementing, the time is being spent elsewhere in the PHP request lifecycle. That's this guide.

Where "elsewhere" actually is, in production, almost always falls into one of five places: synchronous outbound HTTP from a hook callback, OPcache that isn't working, a misconfigured persistent object cache, PHP-FPM worker exhaustion, or a wp_remote_get to a dead third party. Each of these leaves a fingerprint on the supporting signals, which is what the next sections decode.

5. Solution

5.1 Diagnose (logs first)

Work outward from the slow request. Start with the metric, then the request fingerprint, then the hook timing, then the system layer.

Step 1: confirm the slow request is real and find its fingerprint.

grep "http.request" /var/log/wordpress/logystera.log \
  | jq 'select(.execution_time_ms > 5000) | {url, execution_time_ms, peak_memory_mb, fingerprint, request_id}' \
  | head -20

Each hit is a sample contributing to wp_slow_requests_total. Note the fingerprint and request_id. The fingerprint is what wp_request_fingerprint_top aggregates against — it groups requests by route shape (e.g. /wp-admin/admin-ajax.php?action=heartbeat, /?p=, /wp-json/wp/v2/posts/) so you can tell whether one URL is dominating or every URL is slow.

Step 2: rule out memory pressure.

grep "http.request" /var/log/wordpress/logystera.log \
  | jq 'select(.execution_time_ms > 5000) | .peak_memory_mb' \
  | sort -n | tail -20

Each line is a wp_request_peak_memory_mb sample. If peak memory is climbing toward WP_MEMORY_LIMIT (typically 256MB or 512MB), the request is leaking or accumulating, and slow GC pauses become a contributor. If it's flat — say, every slow request is 64MB — memory is not the cause and the time is being spent on I/O.

Step 3: identify the dominant request fingerprint.

grep "wp_request_fingerprint_top" /var/log/wordpress/logystera.log \
  | jq -s 'group_by(.fingerprint) | map({fp: .[0].fingerprint, n: length}) | sort_by(.n) | reverse | .[0:5]'

If 90% of slow requests share one fingerprint (e.g. /wp-json/wc/v3/orders), the problem is route-specific and you should drill into that route's hook stack. If slow requests are spread across many fingerprints, the problem is global — OPcache, object cache, or worker pool — not a specific callback.

Step 4: pull the hook timing for a representative slow request.

grep "perf.hook_timing" /var/log/wordpress/logystera.log \
  | grep "$REQUEST_ID" \
  | jq '{hook, callback, duration_ms, db_time_ms, http_time_ms}' \
  | sort -t: -k4 -n -r

The shape of the output tells you the cause:

  • db_time_ms close to duration_ms → database-bound, this is the wrong guide.
  • http_time_ms close to duration_ms → the callback is making outbound HTTP. This is the most common non-DB cause.
  • Both small, duration_ms still high → CPU-bound (loop, hashing, image processing) or lock contention.

A real slow-by-HTTP entry looks like:

{"event":"perf.hook_timing","request_id":"r_4c1a","hook":"init",
 "callback":"Some_License_Plugin\\Updater::check_remote",
 "duration_ms":4800,"db_time_ms":12,"http_time_ms":4780}

That's a synchronous license check on init, blocking every uncached request for 4.8 seconds.

Step 5: rule out OPcache and object cache misconfiguration.

php -i | grep -E "opcache.enable|opcache.memory_consumption|opcache.max_accelerated_files"
wp eval 'var_dump(wp_using_ext_object_cache());'

If opcache.enable is Off in the FPM SAPI (it's frequently enabled in CLI but disabled in FPM after a deploy), every request recompiles the whole codebase — typically adding 200-800ms of flat overhead, which feeds wp_slow_requests_total across every fingerprint. If wp_using_ext_object_cache() returns false, get_option and get_transient hit MySQL on every call, and the slowness is distributed and invisible to the slow query log because no individual query is slow — there are just thousands of them.

Step 6: check PHP-FPM worker saturation.

grep "child .* still active" /var/log/php-fpm/error.log | tail -20
curl -s http://127.0.0.1/fpm-status?full | grep -E "active processes|listen queue|max children reached"

If listen queue is non-zero or max children reached has incremented, you have worker exhaustion. New requests are queuing at FPM, and their TTFB includes the wait. The slow signal then reports requests that did nothing slow themselves — the cause is upstream of the request boundary.

5.2 Root Causes

(see root causes inline in 5.3 Fix)

5.3 Fix

Each cause maps to a distinct signal pattern. Match the pattern before you change anything.

1. Synchronous outbound HTTP from a hook callback. Pattern: single fingerprint dominates wp_request_fingerprint_top; perf.hook_timing shows one callback with http_time_ms ≈ duration_ms. Common culprits: license checks on init, analytics beacons on template_redirect, currency fetches on wp_loaded, third-party enrichment on the_content. Fix: move the call to WP-Cron, cache the result in a transient (15-60 min TTL), and add a circuit breaker so a dead third party doesn't keep blocking you. wp_slow_requests_total rate should drop within minutes; perf.hook_timing for that callback should leave the top-N list.

2. Missing or broken persistent object cache. Pattern: wp_slow_requests_total increments across most fingerprints, no single hook dominates perf.hook_timing, every request shows hundreds of small DB hits. wp_using_ext_object_cache() returns false despite Redis or Memcached being installed. Fix: drop in the correct wp-content/object-cache.php for your backend, verify with redis-cli ping, and confirm wp eval 'var_dump(wp_using_ext_object_cache());' returns bool(true). wp_slow_requests_total typically falls 60-90% within one cache warm-up.

3. OPcache disabled or undersized in FPM. Pattern: every fingerprint slow on the first request after FPM restart or deploy, fast on subsequent ones. opcache.max_accelerated_files lower than the actual file count (WordPress + WooCommerce easily has 12,000+ files; the default 10,000 isn't enough). Fix: opcache.enable=1, opcache.memory_consumption=256, opcache.max_accelerated_files=20000, opcache.validate_timestamps=0, with explicit reset on deploy. Reload PHP-FPM. wp_slow_requests_total normalizes after the first warm-up.

4. PHP-FPM worker exhaustion. Pattern: wp_slow_requests_total increments on requests with no slow hooks themselves — perf.hook_timing is empty or trivial — and FPM status shows non-zero listen queue or max_children_reached > 0. Usually correlated with one of the above causes. Fix: identify the upstream slow callback first. Raising pm.max_children without fixing the slowness just shifts where the queue forms and risks OOM. Once the callback is fixed, size to RAM / per-worker memory (e.g. 4GB / 80MB ≈ 50 workers).

5. wp-cron piggybacking on user requests. Pattern: random users get spiky TTFB — wp_slow_requests_total increments on requests with an unusually long init-phase callback running scheduled jobs inline. WordPress checks if a cron event is due on every page load and runs it inside the request. Fix: define('DISABLE_WP_CRON', true); in wp-config.php and trigger wp-cron.php from system cron every minute. The spiky outliers in wp_slow_requests_total disappear, leaving a flat baseline.

The error all five fixes share: do not "just enable more caching" without identifying which signal pattern you have. A page cache in front of a broken object cache hides the problem from anonymous users and leaves logged-in users worse off — they're now competing for fewer FPM workers because the cache is absorbing none of their traffic.

5.4 Verify

The metric you watch is wp_slow_requests_total. Healthy is a flat or slow-growing counter; unhealthy is a step-function increase tied to traffic. The rate of increase should drop to near-zero within 15-30 minutes of the fix landing.

grep "http.request" /var/log/wordpress/logystera.log \
  | jq 'select(.execution_time_ms > 5000)' \
  | wc -l

Run this before the fix, 30 minutes after, and 24 hours after. The 24-hour count is the one that matters — short intervals are misled by cache warm-up.

Cross-check the supporting signals:

  • wp_request_peak_memory_mb p95 should stay flat or drop. A fix that moves work to background jobs will lower peak memory; one that just adds caching may not.
  • wp_request_fingerprint_top should no longer be dominated by a single route — or if the problem was global, the whole distribution shifts left.
  • perf.hook_timing entries above your threshold (250ms admin / 100ms frontend) for the offending callback should stop appearing. Even one entry per hour means the fix is incomplete or the callback regressed.
  • The wp_slow_page_load rule should stop firing. If it hasn't fired for one full traffic cycle (24 hours including peak hour), the fix is verified.

Clear the CDN and page cache before measuring. Fastly serving stale-while-revalidate gives you a flattering p50 that has nothing to do with PHP — you'll declare victory while the FPM pool is still saturated for logged-in users.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as wp_slow_requests_total.

A 5-second TTFB does not surface as a fatal error. It does not page you. It does not appear in the WordPress dashboard, the hosting control panel, or your uptime monitor. The MySQL slow log is empty because there is no slow query. The PHP error log is empty because there is no error. Requests are quietly running long, the FPM worker pool is quietly draining, and your users are quietly leaving.

This is what wp_slow_requests_total exists to catch. The metric increments the moment a request crosses 5 seconds, and the wp_slow_page_load rule fires when the rate sustains. Paired with wp_request_peak_memory_mb, wp_request_fingerprint_top, and perf.hook_timing, the alert tells you whether it's a single hook, a single fingerprint, a memory leak, or a system-wide cache miss — without bisecting plugins or rolling back updates and seeing if it helps.

In production, this metric has fired 606 times in 7 days across 2 entities, with 35,564 samples on 5/5 monitored sites. Every WordPress site of meaningful size hits this class of failure regularly. The difference between a fleet that sees it and one that doesn't is whether anyone is watching the right counter.

7. Related Silent Failures

  • wordpress slow queries — finding the plugin or theme responsible — when db.slow_query is firing alongside wp_slow_requests_total, the cause is database-bound; per-hook attribution shifts to MySQL time rather than PHP wall clock.
  • wp.cron / missed_schedule — when in-request cron causes the slowness, scheduled jobs silently fail to run, surfacing as missed schedules hours later.
  • php.fatal / memory_exhausted — slow requests that leak memory eventually trip WP_MEMORY_LIMIT and crash with Allowed memory size exhausted, converting soft slowness into a hard 500.
  • http.request 502/504 — once FPM workers are saturated by slow callbacks, the upstream returns 502s without any PHP error, making the symptom look like infrastructure.
  • wp_remote_get failures to dead third parties — the most common single cause of slow init callbacks; http_time_ms dominates perf.hook_timing and no other signal fires until the connection times out.

See what's actually happening in your WordPress system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.