WordPress at scale

Design a WordPress stack for high traffic, from caching layers and stateless web servers to database replicas, media offloading and search.

  • Intermediate
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Design a high-traffic WordPress stack
  • Remove database bottlenecks
  • Run WordPress on several stateless web servers
  • Protect the site from cache stampedes and write-heavy workloads

Before you start

WordPress runs some very large sites. It does not get there by default. Scaling is about knowing which requests are expensive, keeping as many as possible away from PHP and the database, and removing the bottlenecks that remain. This lesson describes the architecture and the code habits that make it work.

General system design, load balancing and database replication theory have their own courses. Here we cover the decisions specific to WordPress.

Start with the traffic, not the servers

Before choosing infrastructure, describe the load. The numbers come from your analytics and logs, not from rules of thumb.

  • Anonymous page views. Usually the majority, and cacheable.
  • Logged-in traffic. Members, customers with carts, editors. Mostly uncacheable as full pages.
  • Admin and editorial work. Few users, but heavy requests and the Heartbeat API.
  • API traffic. REST requests from apps, a headless frontend or integrations.
  • Write volume. Comments, orders, form entries, imports.
  • Peaks. A newsletter send or a breaking story can multiply traffic in minutes.

A news site with 99% anonymous traffic and a membership site with 60% logged-in users need very different designs, even at the same page view count.

The layered stack

LayerJobWordPress concern
CDNServe assets and cached pages near visitorsCache headers, purging, cookie rules
Load balancerSpread requests across web serversHealth checks, HTTPS termination, real client IP
Web servers (PHP-FPM)Run WordPress for uncached requestsIdentical code, no local state, OPcache
Object cacheShared in-memory dataOne store for all web servers
DatabasePrimary for writes, replicas for readsQuery design, indexes, replication lag
Object storageUploaded mediaOffloading and URL rewriting
Search serviceFull-text and faceted searchIndexing on save, query integration
WorkersCron and background jobsRun on one node or a queue

Each layer exists to protect the ones behind it. The CDN protects the web servers; the object cache protects the database.

Stateless web servers

Running several web servers behind a load balancer only works if any server can answer any request. That means nothing important is stored on one server's disk.

  • Code is identical everywhere and deployed as a release, not changed through the dashboard.
  • Uploads live in shared storage, ideally object storage, not on a single server.
  • Caches live in shared services, not in files on each node. File-based page caches on several nodes drift apart and are hard to purge.
  • PHP sessions are avoided. Core uses cookies, not sessions. Plugins that use PHP sessions need a shared session store or replacing.

A wp-config.php for this kind of setup typically includes:

PHP
// Code changes only arrive through deployments.
define( 'DISALLOW_FILE_MODS', true );

// One scheduler runs cron for the whole cluster.
define( 'DISABLE_WP_CRON', true );

// Fixed URLs, so a node's own hostname never leaks into content.
define( 'WP_HOME', 'https://www.example.com' );
define( 'WP_SITEURL', 'https://www.example.com' );

// Behind a proxy that terminates HTTPS.
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
    $_SERVER['HTTPS'] = 'on';
}

Only trust X-Forwarded-Proto and similar headers when every request reaches PHP through your own proxy. If the web servers are directly reachable, anyone can send those headers.

Caching at scale

The page cache handles anonymous traffic. Its hit ratio is the single most important number for capacity: every miss is a full PHP request. Find what lowers it: query strings such as tracking parameters, cookies set for everyone, and short lifetimes. Many CDNs can ignore known tracking parameters in the cache key.

For logged-in and API traffic, the persistent object cache does the work. With several web servers it must be a shared service, sized so that hot data is not constantly evicted.

Cache stampedes

When a popular cached item expires, every request that misses at the same moment rebuilds it at once. On a busy site that can overwhelm the database. Let one request rebuild while the others serve a stale copy:

PHP
function myplugin_get_report(): array {
    $report = wp_cache_get( 'report', 'myplugin' );

    if ( false !== $report ) {
        return $report;
    }

    // wp_cache_add() only succeeds for the first caller, so it works as a short lock.
    if ( wp_cache_add( 'report_lock', 1, 'myplugin', 30 ) ) {
        $report = myplugin_build_report(); // Your expensive function.

        wp_cache_set( 'report', $report, 'myplugin', 10 * MINUTE_IN_SECONDS );
        wp_cache_set( 'report_stale', $report, 'myplugin', DAY_IN_SECONDS );
        wp_cache_delete( 'report_lock', 'myplugin' );

        return $report;
    }

    // Everyone else gets the previous copy while the rebuild runs.
    $stale = wp_cache_get( 'report_stale', 'myplugin' );

    return false !== $stale ? $stale : array();
}

The lock is only as atomic as the backend's add operation; Redis and Memcached both provide one. The same idea applies at the CDN, where serving stale content while revalidating is a common setting.

Full purges are a stampede by design. Prefer targeted purges, and schedule full purges outside peak hours.

The database

The database is the hardest layer to scale horizontally, so most of the work is making it do less.

Make queries cheaper

  • Profile with the slow query log on the database server as well as Query Monitor. Fix the queries with the highest total time, not just the slowest single one.
  • Keep autoloaded options small.
  • Avoid large meta queries and orderby on meta values; use taxonomies, precomputed values or a search service.
  • Use no_found_rows when you do not paginate, and cap posts_per_page.

Custom tables for high-volume data

wp_postmeta is a key-value table. It is flexible, but queries that filter on several meta values across millions of rows are slow, and the table grows quickly. Data that is written often and queried by several columns, such as orders, events, log entries or analytics, belongs in a custom table with proper columns and indexes, accessed through $wpdb->prepare() and created with dbDelta() in an upgrade routine.

Read replicas

A replica receives copies of every write from the primary and can answer reads. WordPress core talks to one database, but a db.php drop-in can replace the database class to send writes to the primary and reads to replicas. Open-source drop-ins exist for this.

Replication is not instant. If a request writes a value and immediately reads it from a replica, it may get the old value. The drop-ins handle the common case by sending all queries to the primary after the first write in a request. Code that writes in one request and reads in the next (for example, a redirect after saving) can still briefly see stale data, so test those paths.

Media

Uploaded files on a single server's disk do not work across several web servers, and serving them through PHP servers wastes capacity. Offload them:

  • Store uploads in object storage and serve them through a CDN. Plugins for common providers hook into the upload process and rewrite attachment URLs. Under the hood they rely on filters such as upload_dir and wp_get_attachment_url.
  • Limit generated image sizes. Every upload creates a file for each registered size. Remove sizes you do not use:
PHP
add_filter( 'intermediate_image_sizes_advanced', 'myplugin_limit_image_sizes' );

function myplugin_limit_image_sizes( array $sizes ): array {
    unset( $sizes['medium_large'], $sizes['1536x1536'], $sizes['2048x2048'] );

    return $sizes;
}

Check your theme and blocks do not need a size before removing it. An image service that resizes on request at the CDN is the other common approach for very large libraries.

Core search runs LIKE queries against post titles, excerpts and content. On a large site it is slow and the relevance is basic. At scale, index content in a dedicated search engine such as Elasticsearch or OpenSearch. Integration plugins update the index when posts change and route WP_Query searches (and sometimes other heavy queries) to the search service, so templates keep working.

The trade-off is another service to run and an index that can drift from the database. Plan for a full reindex command and monitor indexing errors.

Background work

  • Cron. Run WP-Cron from one scheduler, not from every web server, as the cron lesson showed. Running it on every node causes duplicate jobs.
  • Long jobs. Imports, exports, emails and reindexing belong in a queue processed by workers, not in page requests.
  • Third-party calls. Never make visitors wait on external APIs. Refresh data in the background and cache it.

Logged-in and admin load

Logged-in traffic bypasses the page cache, so it is where capacity runs out. Keep personalised parts small, cache shared fragments in the object cache, and load per-user data through lightweight REST endpoints.

The Heartbeat API sends periodic AJAX requests from open admin screens for autosaves and post locking. With many editors, those add up. You can slow it down, keeping the editor's autosave and locking working:

PHP
add_filter( 'heartbeat_settings', 'myplugin_heartbeat_settings' );

function myplugin_heartbeat_settings( array $settings ): array {
    $settings['interval'] = 60; // Seconds between requests.

    return $settings;
}

Load testing and observability

Test before the traffic arrives:

  • Load-test a staging copy with production-like data and a realistic mix of cached, uncached and logged-in requests. Testing only the home page measures the CDN, not WordPress.
  • Ramp traffic up gradually and watch for the first resource to saturate: PHP workers, database CPU or connections, object cache memory.
  • In production, monitor the page cache hit ratio, PHP response times, error rates, slow queries and object cache evictions. Alert on changes, not just outages.

The goal is to know your current limit and which layer reaches it first.

Summary

  • Design from the traffic mix: anonymous, logged-in, admin, API and writes need different strategies.
  • Keep web servers stateless: identical deployed code, shared uploads, shared caches and no PHP sessions.
  • The page cache hit ratio drives capacity; protect hot items from stampedes with a lock and a stale copy.
  • Make the database do less with better queries, small autoload and custom tables for high-volume data; add read replicas through a db.php drop-in and allow for replication lag.
  • Offload media to object storage and a CDN, limit image sizes, and move search to a dedicated engine.
  • Run cron from one scheduler, queue long jobs, slow the Heartbeat API, and load-test with realistic traffic.

Further reading