Skip to content
Gaurav TiwariDocs

Architecture

Version 1.3.6 Updated October 26, 2026

How the pieces fit, and why several of them are shaped the way they are.

Boot

gt-performance.php registers two autoloaders and boots the plugin on plugins_loaded at priority 1. The plugin’s own classes load from src/ by namespace. The bundled libraries (the minifier, the CSS parser and the CSS selector engine, with their dependencies) load only when one of their classes is first used, not on every request.

Plugin::boot() then:

  1. finishes a pending database schema upgrade (on admin and WP-CLI requests only)
  2. republishes owned drop-ins when the plugin version, the plugin directory or the drop-in source changed
  3. constructs the modules this request can need, and calls register() on each in a fixed order

Every module implements one interface, Module, with a single register() method. Not every request builds every module:

LoadedModules
Every requestPage cache, CDN, queue, commerce, Core Forms and plugin compatibility, optimization, database cleanup, settings history
When the Abilities API exists (WordPress 6.9+)AI abilities
Admin, cron, AJAX, WP-CLI, REST, or a request with a login cookie; any other request once it fires a purgeCloudflare, xCloud, Redis
AdminSettings screens
Admin, or a request with a login cookieAdmin bar
WP-CLICommands

A typical anonymous page view needs none of the edge, admin or CLI modules, so it never pays to construct them. REST requests without a cookie (an agent using an Application Password) load the edge modules at rest_api_init, and any other request loads them the moment it fires a purge (a visitor’s auto-approved comment, a checkout that sells a product out), so every purge still reaches Cloudflare or xCloud.

Activation requires PHP 8.1 and WordPress 6.6, and refuses multisite: one compiled config and one cache directory would be shared by the whole network, so one site’s settings would decide another site’s cache behavior.

The drop-in contract

wp-content/advanced-cache.php is copied verbatim from the plugin’s dropins/ directory. Only one value is stamped into it: the version in its signature line. It is not generated, and the plugin never writes executable PHP at runtime.

The drop-in resolves everything at runtime:

$cacheRoot  = realpath( WP_CONTENT_DIR . '/cache/gt-performance' );
$configFile = realpath( $cacheRoot . '/config.json' );
// decrypt config → get plugin_dir → require six runtime classes → serve

It loads exactly six files (ConfigFile, Decision, RequestContext, Eligibility, CacheKey, DropinRuntime) and calls DropinRuntime::serve(). If the config is missing, cannot be decrypted, or names a plugin directory that no longer exists, it returns and the request falls through to WordPress. So does every request while GTPERF_SAFE_MODE is defined, and every request in optimize-only mode, where the compiled config marks the cache disabled. A missing or broken drop-in means no cache, never a broken site.

On a hit it replays the allowlisted security and indexing headers stored with the page (see Page Cache), re-validating each line, because the code that set them in PHP never runs on a hit.

Because the plugin location is part of the config and not the drop-in, a renamed or relocated plugin directory keeps working. The republish check on every boot compares the plugin version, the plugin directory and the drop-in source’s modification time, so a migration, a restored backup, or an update refreshes the drop-in and its config with no manual step.

The installer never overwrites an advanced-cache.php it does not own (status conflict). Deactivation removes an owned drop-in and puts back whatever WP_CACHE line was there before the installer changed it. The Redis object-cache.php drop-in follows the same rules. See Redis Object Cache.

Runtime configuration is encrypted data, not code

The drop-ins read two files from wp-content/cache/gt-performance/:

FileHoldsRead by
config.jsonThe settings generation, the cache policy (including the allowed hosts and every commerce and compatibility rule), the debug flag, and the plugin directoryadvanced-cache.php
redis-config.jsonThe Redis connection, including its passwordobject-cache.php

Both are authenticated encrypted JSON, written by ConfigFile:

{"version":1,"data":"<base64 of IV | tag | ciphertext>"}
  • AES-256-GCM, with a 12-byte IV and a 16-byte tag
  • the key is SHA-256 of gt-performance-runtime-v1| plus the site’s AUTH_KEY, and is never stored
  • gt-performance-runtime-v1 is bound in as additional authenticated data, so a tampered or truncated file fails to decrypt instead of being half-read

They are read with file_get_contents() and json_decode() and are never included or executed. That protects the Redis password even on a server that ignores .htaccess and serves .json files as text: without AUTH_KEY the file is noise. As defense in depth, both are written with 0600 permissions and the cache root’s .htaccess denies *.json on Apache.

Requirements: the PHP OpenSSL extension, and an AUTH_KEY of at least 16 characters that is not the sample placeholder. Without them the files cannot be written, the drop-ins fall through to WordPress, and the admin reports that settings could not be applied to the runtime cache.

The config is recompiled on every settings save, on drop-in install, on activation, on any plugin activation or deactivation, and whenever the active commerce policy changes. When a write fails, the old files are deleted rather than left enforcing stale rules, and the health report’s Runtime configuration check fails until a save succeeds. Changing AUTH_KEY makes both files unreadable, so save settings once afterwards.

Page metadata (<hash>.meta.json) is plain JSON. It holds only timestamps, a URL and a generation counter, and sits beside the cached .html in a denied directory.

Why both sides must sanitize identically

RequestContext is built twice per uncached request: once by the drop-in before WordPress exists, once by PageCacheModule after it does. Both go through the same static helpers on RequestContext, which strip control characters and bound every method, host, path, name, value and user agent.

If those two ever disagree, one of two things happens:

  • Keys diverge: every page misses forever, and the cache appears to be “not working” with no error anywhere.
  • Bypass decisions diverge: the drop-in serves a cached page to a request WordPress would have bypassed. That is the serious one.

WordPress adds slashes to superglobals in wp_magic_quotes(), which runs after advanced-cache.php. So the WordPress-side builder unslashes and the drop-in does not. The drop-in also reads only the two request headers that can change a decision (Authorization and X-GT-Preload), which keeps other untrusted header values out of the hit path. X-GT-Preload counts only with a valid token: an HMAC over an expiry at most ten minutes ahead, keyed from AUTH_KEY with its own label, which the drop-in checks without a database. Query parameters sent as arrays are recorded by name on both sides and bypass the cache, so ?preview[]=1 is never judged or keyed as the page without a query. A parity test asserts both paths produce identical keys and identical bypass decisions across quoted URLs, control characters, null bytes and header-injection attempts.

Output buffering

Every buffer the plugin opens is registered with an explicit close on shutdown at priority 0, ahead of core’s own wp_ob_end_flush_all() at priority 1. A page cache has to hold its buffer open across the whole template render, so it cannot close it in the function that opened it. Registering the close immediately means the plugin owns its buffers rather than relying on core or on PHP’s implicit end-of-request flush.

The buffer only collects. The transform (optimizers, then the cache write) runs after the buffer is closed, because the optimizers call WordPress asset printers that open buffers of their own, which PHP forbids inside an output handler. If a transform throws, the original HTML is sent unchanged. Buffers close innermost-first, so a CDN rewrite wrapping a page-cache capture still sees the inner result. See OutputBuffer.

Atomic writes

Cached pages, metadata, generated CSS and fonts, both drop-ins, both config files and the wp-config.php edit are written to a temporary sibling and renamed into place, so a reader sees either the old file or the new one, never half of one. A temporary copy of a .php target keeps a .php suffix, so a web server never serves a half-written wp-config.php as text. AtomicFile is the shared helper; WP_Filesystem cannot guarantee a same-filesystem rename.

Settings

All settings live in one option, gt_performance_settings. Unknown keys are dropped on every save, so removed controls do not linger. Every save:

  • takes an exclusive settings lock, so two administrators (or an admin screen and WP-CLI) cannot overwrite each other with a stale read
  • increases generation, which is part of every cache key, when a setting that reaches cached pages changed (settings listed in Settings::OUTPUT_NEUTRAL, such as credentials, warming and background work, do not count)
  • recompiles the runtime config, and keeps the previous settings if that fails
  • records the replaced values as a revision in settings history (the last 20, up to 90 days, credentials never included)

Credentials saved in the option (the Cloudflare token or key, the xCloud token, the Redis password) are encrypted with a purpose-bound key derived from AUTH_KEY and SECURE_AUTH_SALT, using libsodium when available and AES-256-GCM otherwise. Rotating either value makes them unreadable, so enter them again afterwards (credentials defined as constants in wp-config.php are unaffected). See Tools for history, export and import.

Database tables

TableHolds
{prefix}gtperf_jobsThe background job queue
{prefix}gtperf_dependenciesWhich cached pages depend on which posts, terms and listings (see Purging)
{prefix}gtperf_warm_targetsSitemaps and URLs of each warm run (see Cache Warming)
{prefix}gtperf_artifactsUnused-CSS build reports and the generated files they point to
{prefix}gtperf_operationsAI agent operations and settings proposals (see AI and MCP)

Schema upgrades run in bounded steps on admin and WP-CLI requests, never on a visitor’s request. Until one completes, queue work waits and the Tools tab says so.

Locks and SQLite

Schema upgrades, the queue runner and settings writes each take a named lock scoped to the database and table prefix. On MySQL and MariaDB that is GET_LOCK(), which the server releases if the holder’s connection dies. WordPress Studio and Playground run on SQLite, where GET_LOCK() is emulated without locking anything, so there NamedLock uses an expiring row in the options table (gtperf_lock_…) instead.

The queue

Preloads, warm runs, purge jobs, CSS generation, font localization, image variants, database cleanup and agent operations all run through a database-backed job queue rather than inside a visitor’s request. WP-Cron runs it every minute, for up to 25 jobs or 20 seconds, and one runner per site holds the runner lock.

  • Exclusive claims. A job is claimed with a conditional update that sets a random lock token and a 600-second lease. A worker that loses the race gets nothing. Loopback requests a job makes carry its ID and token, so a late response from a superseded worker cannot publish.
  • Bounded retries. A job that throws is retried with backoff, and fails after three attempts. A crashed worker’s lease expires and the job is reclaimed.
  • No duplicates. An identical pending or running job is reused instead of queued twice.
  • Priorities with aging. Purge jobs run first. Other work ages toward the front the longer it waits, so a steady stream of one job type cannot starve another.
  • Pause. Pausing stops optional work only. Purge jobs keep running.
  • Cleanup. Finished, failed and cancelled jobs are pruned after three days.

This is why a stale page is served instantly: the rebuild is queued, not awaited. The queue can be paused, resumed, listed, retried and cancelled from Tools > Background queue or wp gt-performance queue. See Tools and WP-CLI.

Prefixes

Everything the plugin declares globally uses GTPERF_ (constants) or gtperf_ (database tables, transients, lock rows, AJAX and admin-post actions, cron schedules, query parameters, and the default Redis key prefix). Options and hooks use the longer gt_performance_ form.

Sites installed before 1.0.1 used GTP_/gtp_. There is no compatibility shim: constants in wp-config.php must be renamed. See Upgrading.

Module layout

src/
├── Abilities/      WordPress Abilities API: read and operate abilities, agent permissions, MCP
├── Admin/          settings screens, admin bar, per-page editor options
├── AI/             optional adviser: evidence builder and recommendation validation
├── Cache/          drop-in runtime, eligibility, keys, capture, purge, dependencies, warming, cleanup
├── CDN/            origin-pull URL rewriting
├── CLI/            WP-CLI commands
├── Cloudflare/     API client, rule compiler, token provisioning, diagnostics, disconnect
├── Commerce/       FluentCart / EDD / WooCommerce adapters
├── Compatibility/  plugin detection, optimization ownership, Core Forms
├── Configuration/  settings history, diff, export and import, settings lock
├── Contracts/      the Module interface
├── Core/           bootstrap, settings, paths, schema, locks, ciphers, logging, safe mode
├── Database/       background database cleanup
├── Diagnostics/    cache inspector, purge verification and receipts, health report, Site Health
├── Integrations/   recommended defaults when an integration is switched on
├── Operations/     agent operations and settings proposals
├── Optimization/   CSS, JavaScript, media, hero rules, fonts, embeds, speculative loading
├── Queue/          job store, leases, runner
├── Redis/          object-cache drop-in and configuration
├── Setup/          first-run checks, host-cache probe, mode recommendation, verification
└── XCloud/         host integration and edge ownership
Share

Was this helpful?

Still stuck? Open a support ticket