← Engineering journal

Application performance7 min read

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.

Field notes / WordPress engineering

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.

The anatomy of an authenticated requestREQUESTBOOTPOLICYDATARENDERSENDShared readsBATCH / REUSEBackground workBOUNDED / RETRYABLE
A request trace separates work on the critical path from reusable reads and background work. The illustration shows dependencies, not measured durations.

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.

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.