Your site is fast. Until someone logs in.
The performance work that begins when the full-page cache stops answering: request traces, batched reads, revisioned caches and a shorter critical path.
The basic principle
Public pages can often be made fast by caching a finished response and serving the same copy repeatedly. Logged-in and personalised pages are harder because WordPress often has to calculate a different response for each person. Good WordPress engineering therefore means measuring what WordPress is actually doing, eliminating repeated work, intelligently caching reusable pieces of data, keeping external services out of the page-loading process wherever possible, and making absolutely certain that those optimisations never show stale, incorrect or another user’s private information.
A cached homepage tells you how quickly you can serve a prepared document. A member dashboard tells you how well the application works.
Account pages, saved searches, private directories and personalised dashboards usually require fresh decisions about a particular visitor. When a shared full-page cache cannot safely answer the request, WordPress must do the work. That is where an apparently fast site can reveal a very different performance profile.
The answer is not to force personalised HTML into a public cache. It is to understand the request: which work is necessary, which work is repeated, which work blocks the response and which data can be reused without crossing a privacy boundary.
Build a request budget from evidence
Trace one meaningful journey, such as a member opening their dashboard after a subscription change. Separate PHP bootstrap, database access, remote services and template rendering. A wall-clock span around a function includes the time it spends waiting on its dependencies; do not add nested spans together and call the total “request time”.
Use a profiler to find repeated call paths and a query inspector to identify duplicate SQL. Record the route, application version, dataset, user class and cache state with each observation. Keep personal data and credentials out of traces. Instrument representative traffic with a bounded sampling rate; profiling every request at maximum detail can become its own performance problem.
Test cold and warm object caches independently. Compare the median with a tail percentile such as p95 over enough requests to make the distribution meaningful. A single fast warm response does not establish capacity, and a local development trace is not a prediction of production latency.
Autoload, Heartbeat and AJAX: the hidden request budget
The first page response is only part of an authenticated session. WordPress loads shared configuration before rendering the dashboard, and the browser may continue polling after it appears ready. Investigate both the work inside each request and the number of requests the session generates.
Autoloaded options: inspect the bootstrap payload
WordPress groups autoloaded options into its alloptions cache entry. A large payload can contain configuration that is useful everywhere alongside historical plugin data, oversized settings arrays and information needed only on a rarely visited screen. This cost can affect public requests too; a full-page cache can hide it from an anonymous visitor without removing it from the application.
A persistent object cache can avoid repeatedly reading the options table, but fetching and reconstructing a large cached payload still has a cost. Measure cache transfer, cache-entry decoding, PHP memory and time spent consuming options. Do not assume WordPress eagerly unserialises every individual option when it loads alloptions: individual stored values are normally passed through maybe_unserialize() when retrieved through get_option(). The cache backend’s own serialisation is a separate layer.
Audit names and stored byte sizes before examining values. A database report restricted to autoload = 'yes' is incomplete on newer WordPress releases. Use the core function that identifies autoload values rather than hardcoding its internal flags. This read-only example requires WordPress 6.6 or later and belongs in a controlled diagnostic command, not a public endpoint or a theme’s render path.
global $wpdb;
$autoload_values = wp_autoload_values_to_autoload();
$largest = [];
if ($autoload_values) {
$placeholders = implode(', ', array_fill(0, count($autoload_values), '%s'));
$largest = $wpdb->get_results(
$wpdb->prepare(
"SELECT option_name, OCTET_LENGTH(option_value) AS stored_bytes
FROM {$wpdb->options}
WHERE autoload IN ($placeholders)
ORDER BY stored_bytes DESC, option_name ASC
LIMIT 20",
...$autoload_values
),
ARRAY_A
);
if ($wpdb->last_error !== '') {
throw new RuntimeException('Autoload audit query failed.');
}
}The report ranks stored values; it is not a measurement of PHP’s expanded memory footprint or the complete cache entry. Option names can also reveal internal structure, so keep the output private. Track the aggregate payload and repeated access patterns as well as the largest rows.
For a setting used only by an occasional task, disabling autoload may be appropriate. Use the option-autoload API to keep WordPress’s cache handling consistent, and verify behaviour on the site’s supported version. Disabling autoload does not delete the setting; it changes how it is retrieved. Conversely, taking a frequently used option out of the shared payload may add individual lookups. Trace its consumers and test both cold and warm requests.
Do not classify an option as abandoned from its prefix alone. Check active code, scheduled jobs, network context and vendor lifecycle behaviour. A plugin may recreate a setting or reset its autoload choice on its next save. Fix the responsible write path when possible, and make a deletion decision separately from an autoload decision.
Heartbeat: measure the session, not just the page
The Heartbeat API provides polling that features can use for coordination. Editing locks, session checks and some autosave flows depend on this activity. Autosave behaviour varies by editor and implementation; removing Heartbeat is not a universal way to remove all autosave traffic.
Inspect requests with action=heartbeat, the payload keys contributed by core and plugins, and the server callbacks attached to them. A small response can still involve expensive PHP or database work. Remove redundant work from your own callbacks before assuming the polling mechanism itself is the problem.
Where the workflow permits it, adjust the interval through the Heartbeat settings filter, scoped to the actual screen. Account for existing focus and inactivity behaviour and for code that changes the interval later. Test with several tabs, an inactive tab, competing editors and an expired session. An interval that is acceptable on a reporting screen may undermine a post-editing workflow.
For a capacity estimate, use request rate ≈ active polling tabs ÷ polling interval, then add the measured work per request. This is a planning relationship, not a benchmark: tabs can suspend or throttle polling, and plugins may have independent timers. Observe the resulting session instead of assuming every tab follows the nominal interval.
Admin-AJAX: attribute the action behind the URL
Many unrelated operations share admin-ajax.php. Group traces by their validated action and relevant user class, then inspect the handler rather than treating the endpoint URL as one homogeneous workload. WordPress dispatches authenticated requests through action-specific hooks.
The endpoint bootstraps WordPress and runs admin_init before dispatch. Because WP_ADMIN is true there, is_admin() alone does not mean a human is viewing an administration screen. Expensive plugin initialisation guarded only by that check can therefore run during a front-end AJAX call. Use wp_doing_ajax() and the real operation context where appropriate; it is a context check, not an authorisation check.
Use the browser’s initiator trace to find duplicate listeners, independent polling loops and new requests launched before previous ones finish. For application-owned polling, schedule the next request after completion, back off after failures and pause work when the relevant interface is inactive. Debounce suitable user input and discard out-of-date responses. Aborting a browser request does not guarantee that PHP has stopped processing it, especially for operations that write data.
Profile each handler’s queries, remote calls and response size. Keep permission checks at the action boundary and never use a nonce as a substitute for resource authorisation. Moving the same handler to REST can improve the API design, but both paths ordinarily bootstrap WordPress; it does not automatically remove the expensive computation.
Repeat the journey with realistic tab counts and session duration. Report request frequency and per-action latency alongside the page’s initial response time. A dashboard that renders quickly and then generates unnecessary work all afternoon is not finished performance engineering.
Find the work that multiplies
A dashboard that renders twenty cards may perform twenty entitlement checks, twenty counts and twenty identical configuration reads. Each call can look reasonable in isolation. The repeated composition is the defect.
First collect the identifiers required by the response. Fetch the required records in a bounded batch, then render from an explicit view model. Inspect what WordPress has already primed before adding your own layer. If a query deliberately returns only IDs, confirm whether subsequent metadata access creates extra database work.
Batching also matters with a remote object cache. Replacing twenty database queries with twenty sequential network round trips may be an improvement, but it leaves avoidable latency in place. WordPress provides a multiple-key cache API; whether a backend implements it efficiently is a separate question. Verify the installed backend and its supported capabilities rather than assuming that a callable wrapper proves native batching.
Give cached data an explicit identity
A cache key is a claim about equivalence. Two callers sharing a key must be allowed to receive the same value. A key that omits the account, site, locale or visibility rules can be fast and wrong.
Prefer caching stable, narrowly scoped data over complete personalised HTML. Keep authorisation outside the cache where practical. The following pattern caches an account summary after an authoritative access check. The repository and policy methods are application interfaces, not WordPress functions; their implementations must enforce the stated contracts.
function enjin_account_summary(int $account_id, int $viewer_id): array {
enjin_policy()->assert_can_view_account($viewer_id, $account_id);
$repository = enjin_accounts();
// Monotonic revision read from the authoritative record.
$revision = $repository->current_revision($account_id);
$key = sprintf('site:%d:account:%d:v:%d',
get_current_blog_id(), $account_id, $revision);
$found = false;
$value = wp_cache_get($key, 'enjin_account_summary', false, $found);
if ($found) {
return $value;
}
// Data is identical for every authorised viewer of this account.
$snapshot = $repository->summary_at_revision($account_id, $revision);
if ($snapshot === null) {
// A concurrent write invalidated the read; retry through the caller.
throw new RuntimeException('Account changed during summary read.');
}
wp_cache_set($key, $snapshot, 'enjin_account_summary', 300);
return $snapshot;
}The $found argument distinguishes a cache miss from a stored false-like value. A time-to-live of 300 seconds here is an illustrative retention choice, not a freshness guarantee. Revisions change the identity of the data; expiration bounds how long obsolete versions occupy the cache. See the cache-get contract.
This example deliberately pays for an authoritative revision read. That read must itself be inexpensive and cannot be replaced with a stale cached revision while claiming immediate consistency. The snapshot method must obtain a coherent revision and summary, using an appropriate transaction or version check. The caller needs a bounded retry and a clear failure response, not an infinite loop.
Invalidation is a write-path responsibility
Deleting a key after saving a record sounds sufficient until a concurrent reader reconstructs the old value and writes it back after the deletion. Versioned keys reduce this class of stale resurrection, provided the revision advances atomically with the data it describes.
Map every mutation path: an editor save, a REST update, an import, a subscription event and a command-line repair. Updating the revision only in one admin hook leaves the other paths incorrect. Direct SQL also bypasses many WordPress cache-cleaning mechanisms; the application must explicitly maintain any dependent caches and projections.
A persistent object cache is a deployment capability, not an automatic consequence of calling wp_cache_set(). WordPress’s default object cache lives within the request. The object-cache documentation explains that distinction. Design for a cache miss and a cache outage as normal operating states, and test that privacy still holds when the backend is unavailable.
Prevent a fast cache from hiding a slow rebuild
If a popular key expires and many requests rebuild it simultaneously, the cache can amplify load precisely when it stops helping. Options include staggered expirations, bounded background refresh and a short-lived rebuild lease. The right choice depends on whether serving an older value is acceptable for that particular datum.
A rebuild lease needs a genuinely atomic acquisition mechanism, an owner token and carefully defined expiry and release semantics. Do not implement it as a cache read followed by a cache write. Do not assume a cache plugin provides cross-process locking merely because wp_cache_add() works inside one PHP process. Test the backend under concurrent requests.
Stale-while-revalidate can suit a public aggregate. It is a poor default for a revoked permission. Separate low-risk presentation data from decisions that must reflect the current state.
Remove remote services from the critical path
A dashboard should not need a payment provider to answer every page view just to learn a subscription status that the application can maintain locally. Ingest provider events, validate them, update a local projection and reconcile periodically. Keep an explicit policy for unknown or delayed states.
Where a live remote call is unavoidable, set a finite timeout and define the degraded experience. A long timeout can consume PHP workers, causing unrelated requests to queue. Moving nonessential work to a background queue helps only when jobs have retry limits, duplicate protection and operational visibility.
A queue also needs a reliable runner. For deployments using Action Scheduler, its WP-CLI runner allows processing to be driven by infrastructure rather than relying only on visitor traffic. Adding a queue library is not a substitute for deciding what happens when a job fails.
Test correctness at the same time as speed
Measure request latency and throughput under representative concurrency, but also assert that one account never sees another account’s data. Test permission revocation, subscription changes, cold starts, duplicate jobs and an unavailable cache backend. Inspect worker saturation and database contention, not just the browser’s loading indicator.
The strongest optimisation makes the request perform less necessary work while preserving the decisions that make it correct. That is a more durable achievement than a fast homepage hiding a slow application.
Code examples illustrate the architecture and identify application-specific interfaces. Adapt them to your environment and test the failure paths before deployment. Sources are linked alongside the relevant discussion.