Diagnose Caching and Purges
The tools for answering two questions: why is this page (not) cached, and did that purge actually work. Roughly in the order you should reach for them:
- Response headers
- Explain This Page
- Verified purge receipts
- Diagnostic logging
- The health report, Site Health and
doctor
None of them change settings. Only purge verification changes anything at all (it purges the URL it checks).
Response headers
curl -sSI https://example.com/some-page/ | grep -i '^x-gt-'
X-GT-Cache is set by the drop-in, or by WordPress when the drop-in hands the request on:
| Value | Meaning | Needs Diagnostic logging |
|---|---|---|
HIT | Served from the stored page, still fresh | No |
STALE | Served from the stored page past its fresh lifetime, inside the stale window | No |
MISS | Nothing usable was stored; WordPress rendered the page | No |
EXPIRED | The stored page is past its stale window; WordPress rendered the page | No |
REVALIDATE | A preload request skipped a stale copy on purpose so the page is rebuilt | No |
SAFE-MODE | GTPERF_SAFE_MODE is on; nothing is served from the cache, stored or transformed (see Troubleshooting) | No |
BYPASS | The request is not eligible; X-GT-Cache-Reason names the rule | Yes |
DYNAMIC | The request was eligible, but the rendered response failed validation and was not stored; X-GT-Cache-Reason names why | Yes |
OPTIMIZE-ONLY | Optimize-only mode: the page was optimized for the host’s cache and not stored here; X-GT-Cache-Reason is optimize-only | Yes |
With Diagnostic logging off, a bypassed request carries no X-GT-Cache header at all, and a response that fails validation keeps the MISS it was given before rendering (its Cache-Control: no-store, private, max-age=0 is the tell). Bypass rules are not advertised to the public unless you turn logging on.
Other headers worth knowing:
| Header | When |
|---|---|
X-GT-Cache-Reason | With BYPASS, DYNAMIC or OPTIMIZE-ONLY, only while Diagnostic logging is on. The value is an eligibility reason or a validation reason (status:404, not_html, set_cookie, private_cache_control, content_type, donotcachepage, page-option). The drop-in and WordPress sanitize it with the same rule, keeping letters, digits and _ : / . * - and cutting it at 200 characters, so cookie:wordpress_logged_in_ and path:/cart/ arrive as written |
X-GT-Cache-Key | On a hit served by the drop-in, only while Diagnostic logging is on. The first 12 characters of the cache key hash, the same value as cache_hash_short in Explain This Page |
X-GT-Commerce-Cache: BYPASS | Always, on responses a commerce or cache exception keeps dynamic (a bypass path, cookie or query parameter, or a query parameter sent as an array). See Commerce Safety |
No X-GT-Cache header on a public page with logging on usually means the page cache is not active: the drop-in is missing or not owned, or WP_CACHE is off. In store mode, capture does not run until both are in place. See Page Cache.
A cache hit also carries the security and indexing headers the page was rendered with (Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Robots-Tag and the rest of the allowlist on Page Cache). A page stored before 1.1.1 lacks them until it is rebuilt.
Behind Cloudflare or another CDN you may be reading an edge copy rather than your origin. Add a throwaway query parameter to force a real origin render. An unknown parameter bypasses the cache on purpose, so the response shows what WordPress renders now, not what is stored.
Explain This Page
wp gt-performance cache explain --page-url=https://example.com/pricing/
It reports, for one URL of this site, how the cache policy treats it. It does not request the URL; it evaluates the current policy and reads the stored entry from disk. Without --page-url it explains the home page. The flag is --page-url because WP-CLI reserves --url.
| Field | What it tells you |
|---|---|
cacheable, reason | The eligibility decision and the rule that made it, the same reason X-GT-Cache-Reason would show |
cache_key, cache_hash, cache_hash_short | The computed key and its hash; compare cache_hash_short with X-GT-Cache-Key |
origin.state | fresh, stale, expired or missing for the stored page |
origin.bytes, stored_at, fresh_until, stale_until | Size and Unix timestamps of the stored entry |
cloudflare | Whether the Cloudflare integration is on, the compiled rule expression, whether that rule matches this request, and whether edge and origin agree |
policy | The settings generation and how many bypass paths, cookies and query parameters are in force |
cloudflare.agrees is false only in the unsafe case: the managed rule matches the request, a positive Cloudflare edge cache lifetime overrides origin freshness, and the origin says the page must not be cached. cloudflare.disagreement then tells you to set the lifetime to 0. See Cloudflare.
The same report is available as:
- the
gt-performance/explain-urlability, which can also inspect the mobile variant. See AI and MCP. - the adviser task Explain how this page is cached and optimized, which sends a redacted version of it to your AI provider. See AI and MCP.
In wp-admin, Tools > Explain this page shows the same report for a URL you enter: the decision and reason, the origin copy’s state, when it was stored and how long it stays fresh, its size, the short cache key, and (with Cloudflare connected) whether Cloudflare agrees with the origin. It also links to the page in per-request safe mode (Open this page with every optimization off), which only an administrator can open. Nothing is fetched or purged. The admin bar’s Explain this page item opens this panel with the current page’s URL filled in.
A post with Don’t cache this page ticked is reported as not cacheable with the reason page-option.
Source: CacheInspector.
Verified purge receipts
A purge is easy to believe and hard to check. Purge verification is the difference between “I clicked purge” and “the stored page is gone, and visitors now get a clean response”.
Run it from a front-end page with the admin bar’s Purge and verify this URL, or:
wp gt-performance cache verify --page-url=https://example.com/pricing/
Without --page-url it verifies the home page. For that URL it:
- records the stored entry’s state,
- purges the URL at the origin and checks that the stored entry is now
missing, - sends the queued edge purge and waits for Cloudflare to answer,
- requests the public URL twice, the way a visitor would (through your CDN),
- saves a receipt.
| Receipt field | Meaning |
|---|---|
status | verified when every signal below passes, warning when any does not, failed when the edge purge returned an error |
origin_removed | The stored page was gone after the purge |
body_stable | Both public responses had the same body fingerprint (a truncated SHA-256; bodies are never stored) |
safe_response | Both responses were HTTP 200 and neither was private (no Set-Cookie, no private/no-store) |
first_was_old_hit | With Cloudflare on, the first response was a Cloudflare HIT or STALE with a positive Age, which means the edge still served an old copy |
edge_first, edge_second | Status, byte count, Cache-Control, CF-Cache-Status, X-GT-Cache, Age, and whether a cookie was set, for each response |
body_stable is false when the two responses differ, which happens when the page is rendered fresh each time and contains per-request values.
The newest 20 receipts are kept in the gt_performance_purge_receipts option. Tools > Verified purge receipts shows the latest 10, the health report summarizes the latest 5, and the gt-performance/list-purge-receipts ability pages through them.
Ordinary purges do not write receipts. The last Cloudflare purge (accepted, retrying or failed) is shown under Latest Cloudflare purge on the Cloudflare tab. See Purging.
Sources: PurgeVerifier, ResponseSnapshot.
Diagnostic logging
Optimization > Diagnostics > Diagnostic logging (the debug setting). It keeps the last 100 redacted plugin events in the database and adds cache-decision headers and a script-decision comment to public pages. Turn it on while investigating and off afterwards. While it is on:
- plugin events are recorded (see below);
BYPASS,DYNAMICandOPTIMIZE-ONLYresponses carryX-GT-CacheandX-GT-Cache-Reason;- cache hits carry
X-GT-Cache-Key; - when JavaScript minify, defer or delay is on, each optimized page ends with an HTML comment headed
GT Performance JavaScript decisionsthat lists every printed script handle, what was done to it (defer,delayorkeep) and why, for examplejquery: keep - not deferred: jquery has inline code that runs right after it, plus a count of scripts WordPress did not register. View the page source to read it. See JavaScript Optimization.
Turning it on or off publishes the runtime config and, because the setting changes what pages carry, clears the page cache, so each page’s next response reflects the change.
Private diagnostics in the database
Log entries are stored in WordPress, in the non-autoloaded option gt_performance_diagnostic_log. No log file is written anywhere, including inside the cache directory.
- Only the newest 100 entries are kept.
- Each entry is
time(UTC, ISO 8601),level,messageand up to 20contextvalues. - Context values whose key looks like a secret (
token,secret,password,key,cookie,nonce,authorization) are replaced with[redacted]. - Messages and string values have URLs cut down to scheme, host and path (credentials and query strings removed), bearer tokens and
password=/token=/api_key=-style pairs redacted, and are capped at 1,000 characters. - Nothing is recorded while Diagnostic logging is off, errors included.
There is no log viewer in the admin. Read it with WP-CLI:
wp option get gt_performance_diagnostic_log --format=json
[{"time":"2026-09-27T10:15:02+00:00","level":"debug","message":"Response not cached","context":{"reason":"status:404"}}]
Response not cached at debug level is normal: 404s, redirects and cookie-setting responses are supposed to be excluded. Look for error and warning entries such as Queue job failed, Cloudflare purge failed, xCloud cache purge failed, Unused CSS optimization failed; original HTML returned and Queue cron was missing and has been rescheduled.
Plaintext gt-performance.log files left in wp-content/cache/gt-performance/logs/ by older releases are deleted automatically. If they cannot be deleted, the admin shows “Old diagnostic files could not be removed” until the cache directory is writable again.
The option is removed on uninstall when Remove all data when the plugin is deleted is on.
Source: Logger.
Health report and Site Health
The health report is the site-wide view: queue backlog and age, the queue runner’s last run, WP-Cron, cache storage, drop-ins and the cache mode, edge ownership, runtime config publication, purge verification, unused CSS failures, active language and currency plugins, and cache warming. Its check names and messages are translatable. It reads saved state and cheap local checks only; it requests no pages and contacts no edge provider. It lives on Tools > Health, in wp gt-performance health, and as a redacted support download. See Tools.
The same checks run as a WordPress Site Health test, GT Performance health, under Tools > Site Health with a “Performance” badge:
| Worst check | Site Health result | Heading |
|---|---|---|
| pass or info | Good | GT Performance is healthy |
| warning | Recommended | GT Performance has items to review |
| fail | Critical | GT Performance cannot cache or publish correctly |
Checks needing attention are listed in the test’s description, with a link to the GT Performance Tools tab.
wp gt-performance health and wp gt-performance doctor exit with status 1 when any check fails, so monitoring and CI can act on them. Warnings, such as an integration you do not use or an active language plugin, exit 0.
Sources: HealthReport, SiteHealth.
doctor
wp gt-performance doctor
A live environment check, printed as a table:
| Check | Pass when |
|---|---|
| PHP | 8.1 or later |
| WordPress | 6.6 or later |
| Cache directory | wp-content/cache/gt-performance is writable |
| Page drop-in | owned (otherwise missing or conflict) |
| WP_CACHE | enabled |
| Redis drop-in | owned (a warning when you do not use Redis is expected) |
| Cloudflare | Enabled (a warning when you do not use Cloudflare is expected) |
| xCloud | No conflict between xCloud and direct Cloudflare edge ownership |
| WP-Cron | See below |
The command exits with status 1 when any row is fail, and 0 when the worst is a warning.
Cron health
The background queue runs on WP-Cron every minute. When request spawning is enabled (the default), the WP-Cron check passes. With DISABLE_WP_CRON set, it warns when any scheduled event is more than 15 minutes overdue, and doctor prints a runner line for your server’s crontab:
*/5 * * * * flock -n /tmp/gt-performance-wp-cron.lock '/usr/bin/php' '/path/to/wp/wp-cron.php' >/dev/null 2>&1
(the PHP binary and path are filled in for your server). The health report and its support export say the same without the command, because the runner line carries an absolute server path; they point you to doctor for it. Running wp-cron.php covers every GT Performance event: the queue, garbage collection, Cloudflare purge retries and scheduled database cleanup. To drain only the queue by hand:
wp gt-performance queue run --limit=100
The health report also warns when the queue runner has not reported in for more than 15 minutes. See Tools.
Source: CronHealth.