Every site owner dreams of going viral: the moment an article lands on Reddit, on Wykop (the Polish link aggregator) or on the front page of Onet.
For the server administrator, that moment often turns into a nightmare. The page starts taking 30 seconds to load, and then visitors only see “Error 500: Internal Server Error” or “503 Service Unavailable”.
This article is based on our talk at a Fuckup Night. It shows how to prepare WordPress for high traffic and why a caching plugin alone is not enough.
How the PHP worker limit caps WordPress traffic
Most WordPress outages do not come from a lack of RAM or CPU, but from running out of PHP workers.
What a PHP worker is
Think of a PHP worker as a cashier in a shop.
- You have 10 cashiers (10 workers).
- Each customer (visitor) needs 1 second to be served (the page generation time).
- You can serve at most 10 customers per second.
When the 11th customer arrives, they wait in the queue. When the queue fills up, the server returns a 503 error.
In PHP-FPM, that “cashier” is a child process of the pool. The PHP documentation describes the pm.max_children directive directly: it sets the limit of simultaneously served requests (php.net, FPM configuration). Requests above that limit wait in the socket queue, whose length is set by listen.backlog (511 by default on Linux).
How to calculate PHP worker throughput
Maximum traffic (RPS) = number of workers / average page generation time
An example calculation for 10 workers on shared hosting:
- The page is generated in 0.5 s, so you can take 20 requests per second.
- The page is generated in 2.0 s, so you can take only 5 requests per second.
The same formula shows what the 10,000 users per hour in the title mean. That is on average fewer than 3 page views per second, a number that even 10 workers handle at 0.5 s per page. The trouble starts when traffic does not spread evenly but arrives within a few minutes of a link being published, or when a single page view triggers several PHP requests (AJAX, REST API, cart).
Takeaway: to scale, you either hire more cashiers (more expensive VPS or dedicated servers) or make the cashiers work faster (code optimisation and caching).
How to set pm.max_children in PHP-FPM
You set the number of workers in the PHP-FPM pool file (usually www.conf). The pm directive accepts three values (php.net):
pm mode | How it works | When it makes sense |
|---|---|---|
static | a fixed number of processes equal to pm.max_children | dedicated server with predictable traffic |
dynamic | the number of processes varies between pm.min_spare_servers and pm.max_spare_servers, never above pm.max_children | most VPS servers |
ondemand | processes start only when a request arrives and die after pm.process_idle_timeout (10 s by default) | many small pools on one server |
In dynamic mode, the number of start processes pm.start_servers defaults to the average of min_spare_servers and max_spare_servers.
Memory, not ambition, sets the upper bound of pm.max_children. An example calculation: the server has 8 GB of RAM, and MySQL, Redis and the system take 3 GB. If a single PHP-FPM process running WordPress and WooCommerce takes about 100 MB at peak (measure it on your own server, for example with the RSS column in ps), 5 GB are left for PHP, which means about 50 processes. Setting 200 workers in this situation does not raise throughput; it pushes the server into swap when the traffic wave arrives.
Two directives help with memory leaks and hung scripts:
pm.max_requestsrestarts a process after a set number of requests. The PHP documentation presents this as a workaround for memory leaks in third-party libraries. The default is 0, meaning no limit.request_terminate_timeoutkills a process that serves a single request for longer than the set time, whenmax_execution_timefor some reason does not stop the script. The default is 0, meaning off.
How to check whether PHP-FPM has too few workers
Instead of guessing, enable the pool status page with the pm.status_path directive (for example /fpm-status) and expose it through Nginx to administrative addresses only. The fields described in the FPM status page documentation answer the worker question directly:
listen queue: how many requests are waiting for a free process right now.max listen queue: the longest queue since the pool started.active processesandidle processes: how many processes are working and how many are waiting.max children reached: a value of 1 or more means the pool hitpm.max_childrenat least once.slow requests: the number of requests that exceededrequest_slowlog_timeout.
All counters reset when FPM restarts. If max children reached grows and the queue is not empty, you either lack workers or pages are generated too slowly. To tell which of the two, set request_slowlog_timeout (for example 5 s) and a slowlog path. PHP then writes a backtrace of every slow request, which names the plugin and the function holding the worker.
How a full page cache skips PHP under heavy traffic
The fastest PHP is the PHP that never runs.
If your page is static (for example a blog article), there is no reason for PHP to build it from scratch every time. You should serve a finished HTML file.
Types of full page cache in WordPress
- Plugins (basic): WP Rocket, W3 Total Cache. They write HTML files to disk. That is enough for small sites, but when the request still goes through PHP, the worker is busy despite the cache.
- Server-side cache (professional):
- Varnish: a proxy server that keeps finished pages in RAM in front of Apache or Nginx.
- Nginx FastCGI Cache: built into Nginx. Very efficient.
- Cloudflare (edge cache): serves the page from Cloudflare servers before the traffic even reaches your hosting.
Rule: anonymous (logged-out) traffic should not touch PHP at all. Every page view served from cache is a free worker for the cart, the dashboard and logged-in users.
How to configure Varnish for WordPress and cookies
After installation, Varnish often caches almost nothing on WordPress, and that matches the documentation. According to the Varnish guide on increasing the hit rate, the default configuration does not store responses with a Set-Cookie header, and when the client sends a Cookie header, Varnish bypasses the cache and goes straight to the backend. On a site with Google Analytics or a consent banner, almost every visitor has some cookie.
The fix from the documentation is to remove cookies in vcl_recv wherever the backend does not need them. WordPress recognises a logged-in user by cookies with prefixes defined in core (wordpress_, wordpress_sec_, wordpress_logged_in_), and a commenter by comment_author_. Those must reach PHP; the rest can be removed:
sub vcl_recv {
if (req.url ~ "^/(wp-admin|wp-login\.php|cart|checkout|my-account)") {
return (pass);
}
if (req.http.Cookie !~ "(wordpress_|wp-postpass_|comment_author_|woocommerce_items_in_cart|wp_woocommerce_session_)") {
unset req.http.Cookie;
}
}The cart and checkout paths in the example are illustrative; match them to your shop’s slugs. Newer Varnish versions split the built-in vcl_recv into smaller subroutines, and the description of the built-in VCL shows that the cookie rule can be switched off on its own through vcl_req_cookie. Do that only when the backend always decides by itself what may be cached.
How to purge the Varnish cache after publishing a post
A long object lifetime raises the hit rate, but after a post is edited the reader has to see the new version. The Varnish documentation on purge and ban describes two mechanisms:
acl purge {
"localhost";
"192.168.55.0"/24;
}
sub vcl_recv {
if (req.method == "PURGE") {
if (!client.ip ~ purge) {
return (synth(405, "Not allowed."));
}
return (purge);
}
}- Purge removes one object together with all variants that come from the
Varyheader. The cache-clearing plugin sends aPURGErequest to the post URL. Theacllist is mandatory, because without it anyone on the internet can empty your cache. - Ban works as a filter on objects already stored in the cache, for example “everything under
/category/”. Bans that check onlyobj.*are processed in the background by the “ban lurker” thread. The documentation recommends avoidingreq.*in bans, because the request object is not available in that thread.
How a Redis object cache takes load off the WordPress database
What about WooCommerce shops or customer dashboards that cannot be fully cached as HTML? There, PHP has to work.
On every load, WordPress runs dozens of the same SQL queries: “get the site options”, “get the category IDs”, “get the user metadata”.
Redis (object cache) keeps the results of those queries in RAM. Instead of asking MySQL, PHP asks Redis.
How many queries disappear depends on the theme and the plugins. You can check it yourself: Query Monitor shows the number of SQL queries on a given page before and after Redis is switched on.
In 2026, running WooCommerce without Redis is asking for trouble.
What the object-cache.php file changes in WordPress
WordPress has an object cache of its own, only a non-persistent one. The WP_Object_Cache class documentation says plainly that by default the cache keeps data in memory only and only until the end of the current request. Every page view starts with an empty cache and asks the database again.
Persistence comes from a drop-in. The wp_start_object_cache() function loads the wp-content/object-cache.php file if it exists, and from then on wp_cache_get(), wp_cache_set() and wp_cache_delete() talk to Redis instead of an array in process memory. Plugins such as Redis Object Cache in practice do one thing: they copy this file and give it the connection details.
Three consequences that are easy to miss:
- Transients leave the database. When an external object cache is active,
set_transient()anddelete_transient()store data throughwp_cache_*and not in thewp_optionstable (set_transient). Flushing Redis therefore also deletes all transients. - Not everything goes to Redis. Core marks the
counts,pluginsandtheme_jsongroups as non-persistent through wp_cache_add_non_persistent_groups(), so they still live only in the memory of one request. - When WordPress itself suggests Redis. Since version 6.1, Site Health recommends a persistent object cache on every multisite installation, and when autoloaded options exceed 500 entries or 100,000 bytes, or when any of the comments, options, posts, terms or users tables has at least 1,000 rows. The thresholds can be changed with the site_status_persistent_object_cache_thresholds filter.
How to set maxmemory and the eviction policy in Redis
Redis without a memory limit grows until RAM runs out. On 64-bit systems, maxmemory 0, meaning no limit, is the default (redis.io, eviction). On a server that shares memory with PHP-FPM and MySQL, set the limit explicitly:
maxmemory 512mb
maxmemory-policy allkeys-lruThe 512 MB size is an example, not a recommendation; size it to your data. The policy matters more. The Redis documentation calls allkeys-lru a good default when some keys are read far more often than the rest, which is the typical picture of a WordPress cache. The volatile-* policies evict only keys with an expiry set, and when there are no such keys they behave like noeviction, meaning Redis starts returning errors on writes. In WordPress, wp_cache_set() has $expire = 0 by default, so a large share of keys may have no TTL. A volatile-lru policy on such a cache ends in write errors instead of replacing old data.
To check whether the cache is working, use INFO stats: the hit rate is keyspace_hits / (keyspace_hits + keyspace_misses). A high evicted_keys with a low hit rate means the memory limit is too small or the policy evicts the wrong keys.
How to exclude the WooCommerce cart from the cache
The WooCommerce documentation on configuring caching plugins lists three pages that must stay dynamic because they show one customer’s data: cart, checkout and my account. It also lists the cookies the cache has to give way to: woocommerce_cart_hash and woocommerce_items_in_cart track changes in the cart, and wp_woocommerce_session_ identifies the customer’s cart. If a database cache on the server covers sessions, _wc_session_ has to be excluded from it.
The same documentation warns that some hosts with server-side caching cache the my account page, which ends in a loop when resetting a password. That is the first symptom to check when customers report that they “cannot change the password” after Varnish was switched on.
How to configure Nginx FastCGI cache instead of Varnish
If the server runs Nginx anyway, a separate Varnish is not required. The ngx_http_fastcgi_module module caches PHP-FPM responses on disk and keeps the keys in shared memory. The Nginx documentation states that a 1 MB zone holds about 8 thousand keys.
fastcgi_cache_path /var/cache/nginx/wp levels=1:2 keys_zone=wp:10m inactive=60m;
server {
set $skip_cache 0;
if ($http_cookie ~* "wordpress_logged_in_|wp-postpass_|comment_author_|woocommerce_items_in_cart|wp_woocommerce_session_") {
set $skip_cache 1;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php-fpm.sock;
include fastcgi.conf;
fastcgi_cache wp;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_lock on;
fastcgi_cache_use_stale updating error timeout;
fastcgi_cache_background_update on;
}
}What the key directives do, according to the Nginx documentation:
fastcgi_cache_bypassdoes not take the response from the cache, andfastcgi_no_cachedoes not store it, when any of the values is non-empty and not equal to “0”. That is why both get the same variable in the example.fastcgi_cache_lock onlets only one request through to PHP to fill a new cache entry. The others wait. This is what protects the workers when, after a link is published, hundreds of people open the same address that is not cached yet.fastcgi_cache_use_stale updatingtogether withfastcgi_cache_background_update onserves the reader an older copy and refreshes it in the background.- The
inactiveparameter removes entries nobody has read for the given time, regardless of their freshness. The default is 10 minutes.
Why WordPress went down on Black Friday
Here is the story of one of our outages, which we analysed at a WordUp meetup.
Patient: a news site. Symptoms: a sudden traffic spike (Black Friday), the server went down in 3 minutes. CPU at 100%.
Causes of the outage
The killer AJAX: A “Visitor Counter” plugin sent an AJAX request (
admin-ajax.php) on every visit to count it.- AJAX in WordPress always starts the whole PHP engine (all plugins). The admin-ajax.php file loads
wp-load.phpand then the admin dashboard files as well. - That request was NOT cached.
- 10,000 visits = 10,000 full WordPress boots. The PHP worker limit was exhausted in 10 seconds.
- AJAX in WordPress always starts the whole PHP engine (all plugins). The admin-ajax.php file loads
No indexes in the database: The posts table had 500,000 rows. A “Related posts” plugin ran
ORDER BY RAND(). That killed the MySQL database.
How the outage was fixed
- AJAX switched off: we replaced the counter with Google Analytics (client-side JavaScript).
- Redis: deploying Redis cut the MySQL load by 90%.
- Varnish: we configured Varnish to serve the home page from RAM.
Result in this outage: the server that choked at 100 users suddenly served 5,000 users at once, and CPU dropped to 5%. These are figures from one site, not a norm for every installation.
How to monitor WordPress performance
Do not guess “why it is slow”.
- Query Monitor (dev): a plugin that shows slow SQL queries and memory use on the current page.
- New Relic (pro): advanced APM monitoring. It shows exactly which PHP function or plugin takes the most time.
- PHP-FPM status:
pm.status_pathand the slowlog, described above. - Redis:
INFO statsandINFO memory. - Logs:
tail -f /var/log/nginx/error.log(see our SSH guide).
What a high-traffic checklist for WordPress looks like
Preparing for a traffic spike?
- Hosting: pick one with Redis and a configurable PHP worker limit (for example a dedicated server or a cloud VPS).
- Workers: calculate
pm.max_childrenfrom RAM and enablepm.status_path. - Cache: configure Nginx FastCGI Cache or Varnish, with a list of cookies that bypass the cache and an ACL for PURGE.
- Database: install an object cache plugin (for example Redis Object Cache), set
maxmemoryandallkeys-lru. - Shop: exclude cart, checkout and my account from the cache.
- Clean-up: switch off plugins that use
admin-ajax.phpon the front end. - CDN: move images and CSS to Cloudflare.
A stable WordPress is not magic. It is arithmetic. Cut generation time (cache) and raise throughput (workers and Redis).
Need help with scaling? Our WordPress specialist service prepares sites for high traffic. See WordPress pricing.







