Skip to content

Page Cache

Gaurav Tiwari edited this page Aug 27, 2026 · 1 revision

Page Cache

GT Performance stores a rendered page as a file and serves it back from advanced-cache.php, before WordPress boots. A hit costs one config read, one hash, and two file reads.

The request path

wp-settings.php
  └─ advanced-cache.php          ← the drop-in
       ├─ read wp-content/cache/gt-performance/config.json.php
       ├─ build a RequestContext from $_SERVER / $_COOKIE
       ├─ Eligibility::decide()  ← may bypass here
       ├─ CacheKey::hash()
       └─ serve pages/<xx>/<hash>.html  → exit
  └─ (miss) WordPress loads normally
       └─ PageCacheModule captures the response and stores it

Nothing in the hit path touches the database or the plugin autoloader.

Whether a request is eligible

Checked in this order by Eligibility. The first failure wins and becomes the bypass reason:

Check Bypasses when
cache_disabled Origin caching is off
method Not GET or HEAD
host_missing No Host header
authorization An Authorization header is present
signed_bypass X-GT-Performance-Bypass is present
path:… Path matches a bypass path, on segment boundaries
query:… A query parameter is on the bypass list
unknown_query:… Any query parameter that is not on the ignored list
cookie:… A cookie name starts with a bypass prefix

That unknown_query rule is the important one and it surprises people: an unrecognised query parameter bypasses the cache rather than creating a new cache entry. This is deliberate. It means an attacker cannot fill your disk by requesting ?a=1, ?a=2, ?a=3, and it means a parameter your site actually uses can never be silently ignored into a wrong hit. Add parameters you want ignored (stripped from the key, still cached) to ignored_query_params.

Defaults:

  • Ignored (cached, parameter dropped from the key): fbclid, gclid, gbraid, msclkid, utm_campaign, utm_content, utm_medium, utm_source, utm_term
  • Bypassed: add-to-cart, customize_changeset_uuid, elementor-preview, fluent-cart, preview, s, wc-ajax, gtperf_verify, gtperf_css_preview
  • Bypass paths: /wp-admin/, /wp-login.php, /wp-cron.php, /wp-json/, /xmlrpc.php
  • Bypass cookies: comment_author_, wordpress_logged_in_, wordpress_no_cache, wp-postpass_

Commerce adapters add their own paths, cookies and parameters on top. See Commerce Safety.

The cache key

scheme | host | path | sorted-query | variant | generation

hashed with SHA-256. variant is public, or mobile when Separate mobile cache is on and the user agent matches Mobile|Android|iPhone|iPad. generation is a counter you can bump to invalidate everything at once without deleting files.

The drop-in and WordPress build this key through the same code, and a test asserts they agree byte for byte. If they ever diverge, every page misses forever — or worse, the drop-in reaches a different bypass decision than WordPress and serves a cached page to a signed-in visitor.

What is never stored

Even on an eligible request, the rendered response is validated before it is written:

Reason Condition
status:NNN Anything other than HTTP 200
not_html Empty body, or no <!doctype html> / <html>
set_cookie The response sets any cookie
private_cache_control Cache-Control contains no-store or private
content_type Content-Type is not text/html
donotcachepage DONOTCACHEPAGE is defined and truthy

The set_cookie rule is what stops a personalised response leaking. If a plugin starts a session or sets a preference cookie while rendering, that page is not stored — no configuration required.

Freshness

Three windows, all configurable:

Setting Default Meaning
fresh_ttl 3600s Served as HIT
stale_ttl 86400s Past fresh, served as STALE while a rebuild is queued
browser_ttl 300s max-age sent to the visitor's browser
stale_if_error 86400s Emitted as stale-if-error for shared caches

A stale entry is still served instantly. The preload queue rebuilds it in the background, so visitors never wait for a regeneration. A request carrying X-GT-Preload deliberately skips the stale copy and falls through to WordPress, which is how a preload actually refreshes content instead of reading its own stale output.

Response headers

Header Values
X-GT-Cache HIT, STALE, MISS, EXPIRED, REVALIDATE, BYPASS, DYNAMIC
X-GT-Cache-Reason The bypass reason, only when Debug mode is on
X-GT-Cache-Key First 12 characters of the key hash
ETag SHA-256 of the stored body; conditional requests get a 304
Age Seconds since the entry was stored

BYPASS and DYNAMIC reason headers are suppressed unless Debug mode is enabled, so a production site does not advertise its bypass rules. If you are debugging and see no X-GT-Cache header at all, that is a bypass with debug off.

Purging

Trigger Default behaviour
Publishing or updating a post Post URL, home, its archives and feeds (related)
Comment posted, edited, approved, deleted That post's URL
Menu updated, theme switched, customizer saved Everything
Manual Admin bar, Purge GT cache button, or WP-CLI

Post-publish behaviour is switchable between Post URL only, Related pages and Entire page and edge cache, or automatic purging can be turned off entirely.

Every purge produces a receipt recording what was requested and what was actually evicted, including the Cloudflare result. See Diagnostics.

Storage layout

wp-content/cache/gt-performance/
├── config.json.php          ← compiled cache config (inert JSON, see Architecture)
├── redis-config.json.php    ← Redis runtime config
├── pages/<xx>/<hash>.html   ← cached body
├── pages/<xx>/<hash>.meta.json
├── assets/                  ← generated CSS/JS/fonts (publicly reachable by design)
├── locks/
└── logs/

pages/, locks/ and logs/ get an index.html and a deny-all .htaccess. assets/ stays reachable because the browser must fetch generated CSS and JS from it.

Entries are written to a temporary sibling and renamed into place, so a request can never read a half-written page.

Related

Cache Exclusions · Commerce Safety · Private Islands · Architecture · Diagnostics

Clone this wiki locally