We may earn a referral fee at no additional cost to you when you shop through the links on this page.
Your WooCommerce store crawls or starts throwing 502 errors the moment traffic climbs, yet top shows spare CPU and the database looks idle. Nine times out of ten the bottleneck is an untuned PHP-FPM pool: too few workers to absorb the concurrency, or so many that the server runs out of memory and starts swapping.
Prerequisites
This is a server-level guide. You will need:
- Root or
sudoSSH access to the server. - PHP-FPM 8.x already installed and serving the site (nginx, or Apache with
mod_proxy_fcgi). - A WordPress or WooCommerce site running under real or anticipated traffic.
What the PHP-FPM Pool Settings Actually Control
PHP-FPM serves your site from a pool of worker processes, and each worker handles exactly one request at a time. This guide is PHP-FPM specific: pool tuning has no equivalent under Apache’s older mod_php, so if your host still runs mod_php these settings will not apply. Tuning is really just two decisions: how many workers you allow, and how the pool decides when to start and stop them. Six directives cover almost everything that matters.
| Directive | What it sets |
|---|---|
pm | The process manager mode: static, dynamic, or ondemand |
pm.max_children | The hard ceiling on simultaneous worker processes |
pm.start_servers | Workers created at startup (dynamic only) |
pm.min_spare_servers | Minimum idle workers kept ready (dynamic only) |
pm.max_spare_servers | Maximum idle workers allowed (dynamic only) |
pm.max_requests | Requests a worker handles before it respawns (0 means never) |
The pm directive is the first fork in the road, and it accepts one of three values: static, dynamic, or ondemand. The other five directives shape how aggressively the pool scales between idle and busy. Get pm.max_children wrong in either direction and the site suffers, so that is the number to derive from real data rather than copy from a blog.
Step 1: Measure Your Real Average Worker Size
Every tuning calculator online asks for your average PHP-FPM process size, then never tells you how to find it. You get it straight from the workers already running on your server. Each PHP-FPM worker is a process, so its resident memory (RSS) is a reliable estimate of what one request costs you.
Run this while the site is under normal traffic. It reads the RSS of every php-fpm8.3 worker (use your installed version) and prints the average in megabytes:
ps --no-headers -o rss,args -C php-fpm8.3 | awk '/pool/ { sum+=$1; n++ } END { if (n) print int(sum/n/1024) "M"; else print "no php-fpm pool workers running" }'
48M
If the command prints “no php-fpm pool workers running”, the process name does not match your installed version, so swap 8.3 for yours (run systemctl list-units 'php*-fpm.service' to check). Run it a few times across a busy period and take the highest figure you see, not the quietest, because you are sizing for load, not for an idle box.
Step 2: Calculate pm.max_children From Your RAM
pm.max_children is the maximum number of PHP-FPM workers that can run at once, and because each worker handles one request at a time, it is effectively your concurrency limit. The RAM formula gives you a ceiling, the most workers your memory can hold without swapping, not the number you should actually run: pm.max_children = (Total RAM - RAM reserved for everything else) / Average worker RAM. Size it against the memory you can spare for PHP, not the server’s total RAM.
“Everything else” is the database, the web server, Redis if you run it, and the operating system itself. On a typical 4 GB VPS running MySQL and nginx, reserving around 1.5 GB for those leaves about 2.5 GB for PHP. At the 48 MB per worker we measured above, that is roughly 2560 / 48, or about 53 workers. That 53 is your raw memory ceiling; round it down for headroom and set pm.max_children = 45 as the value you actually run.
That ceiling is a limit, not a target. On most VPS boxes the CPU cores saturate long before the memory does, and past that point extra workers just queue and fight, so response times get worse rather than better. Treat the RAM figure as the number you must never exceed, then find the real optimum empirically: push realistic load at your checkout, watch response time and free -m together, and raise the worker count only while both keep improving. A benchmarked VPS tune that ran the same WooCommerce store at several pool sizes landed on a sweet spot well below the memory ceiling, with the larger pools measurably slower.
When every worker is busy, further requests wait in a queue. If that queue fills, nginx has nowhere to hand the request and returns a 502 Bad Gateway. It is tempting to fix that by raising pm.max_children, but only raise it if both the RAM math still holds and your cores are not already saturated. Setting it higher than your memory supports is worse than a 502: the server starts swapping, every request slows to a crawl, and eventually the kernel’s OOM killer starts terminating processes.
pm.max_children multiplied by your average worker size must comfortably fit in the RAM you set aside for PHP. A value that looks generous on paper is what pushes a small server into swap. Step 3: Choose a Process Manager Mode
The pm mode decides how the pool sits between requests. There is a clear default and two situational alternatives.
| Mode | How it behaves | Best for |
|---|---|---|
dynamic | Keeps a band of idle workers ready and scales up to pm.max_children under load | Most WordPress and WooCommerce sites |
ondemand | Starts no workers until a request arrives, then kills them after an idle timeout | Low-traffic sites, or many sites sharing one small box |
static | Runs a fixed pm.max_children workers at all times | A dedicated high-traffic box where predictable latency matters |
In dynamic mode three more directives shape the idle band. pm.start_servers sets how many workers spin up when PHP-FPM starts, while pm.min_spare_servers and pm.max_spare_servers set the minimum and maximum number of idle workers the pool keeps waiting for the next request. Keeping a few spares ready means the first visitor after a quiet spell does not wait for a worker to fork.
ondemand trades that readiness for memory. It starts nothing until traffic arrives, and pm.process_idle_timeout controls how long an idle worker survives before the master process shuts it down to free memory. That is ideal when you host several low-traffic sites on one server and want none of them holding RAM they are not using. static is the opposite bet: it keeps the full worker count resident so there is never a fork delay, which suits a single busy store on its own box.
Start with dynamic. Move to static on a dedicated box where you have measured that fork latency is costing you, or when you want a fixed worker count that is easier to load-test against, and reach for ondemand when RAM is the scarce resource across many small sites.
Step 4: Recycle Workers With pm.max_requests
A single PHP-FPM worker stays alive across thousands of requests, and a heavy plugin stack slowly leaks memory into it. Left alone, a worker’s RSS creeps upward for hours until the pool is quietly using far more memory than your calculation assumed. pm.max_requests is the safety valve.
Set it, and each worker respawns after handling that many requests, returning its accumulated memory to the system. The PHP manual is blunt about why it exists: it is there to work around memory leaks in third-party libraries. A value of 0 means workers never respawn, which is exactly the wrong choice for a plugin-heavy WooCommerce site. A value between 500 and 1000 keeps memory flat without respawning so often that you lose the benefit of warm workers.
Tuning Profiles: Light WordPress vs Heavy WooCommerce
A basic WordPress page might run 20 to 40 database queries. A WooCommerce product page with variations, reviews, related products, and dynamic pricing can run 200 to 500 or more queries per page load, and the cart and checkout add session handling, stock checks, tax math, and payment-gateway calls on top. That work lives in the PHP worker, so a WooCommerce worker is simply bigger than a blog’s. Two starting profiles, both for dynamic mode:
Light WordPress Blog (2 GB Server)
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
Heavy WooCommerce Store (4 GB Server)
pm = dynamic
pm.max_children = 25
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 8
pm.max_requests = 500
The WooCommerce box has twice the blog’s RAM but only a few more workers, not double, because each WooCommerce worker is larger and eats more memory. That is the whole point of measuring first: a store runs fewer workers per gigabyte than a blog does, so sizing it by copying a blog’s workers-per-gigabyte ratio would push it into swap. Object caching takes real load off these workers too, so pairing this with Redis object caching for WordPress lets each worker finish faster and serve more requests.
Apply, Reload, and Verify With the FPM Status Page
The pool file lives at /etc/php/<version>/fpm/pool.d/www.conf on Ubuntu and Debian. If you are unsure which version is active, the PHP-FPM configuration file location guide walks through finding it. Substitute your installed PHP version for 8.3 in the commands below (run systemctl list-units 'php*-fpm.service' if you are unsure which is active). Open it with a text editor:
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
Set your chosen values, and while you are in the file, enable the status page by uncommenting and setting this line. It is the single best way to see whether your numbers are right:
pm.status_path = /status
Save and exit (press CTRL + X, press Y, then press ENTER)
Changes to this file do nothing until you reload the versioned php-fpm service. A reload applies the new configuration without dropping in-flight requests:
sudo systemctl reload php8.3-fpm
Confirm the service came back cleanly:
systemctl status php8.3-fpm
● php8.3-fpm.service - The PHP 8.3 FastCGI Process Manager
Loaded: loaded (/usr/lib/systemd/system/php8.3-fpm.service; enabled)
Active: active (running)
The status page reports what the pool is actually doing: how many workers are active, how many are idle, and whether requests are backing up in the listen queue. PHP-FPM will not serve /status over HTTP on its own; nginx has to route that path to the FPM socket. Add this location block to your site’s nginx server block, locked to localhost so the pool’s internals are never exposed publicly. On Apache with mod_proxy_fcgi the idea is identical: pass /status to the FPM socket with a localhost-restricted <LocationMatch> and SetHandler, then reload Apache instead of nginx.
location = /status {
allow 127.0.0.1;
deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
Reload nginx so the new location takes effect, then read the page from the server itself with curl:
sudo systemctl reload nginx
curl -s -H "Host: devanswers.test" http://127.0.0.1/status
pool: www
process manager: dynamic
idle processes: 3
active processes: 2
total processes: 5
listen queue: 0
A healthy pool shows a listen queue at or near zero and active processes comfortably below pm.max_children. If active processes are pinned at the ceiling and the listen queue is climbing, your workers are the bottleneck and it is time to revisit Step 2.
Troubleshooting: 502s, Reached max_children, and Swapping
Three failure patterns cover almost every PHP-FPM tuning problem. The PHP-FPM log at /var/log/php8.3-fpm.log (or journalctl -u php8.3-fpm) is where the pool tells you which one you have.
| Symptom | Cause | Fix |
|---|---|---|
| 502 Bad Gateway under load | Every worker busy, the request queue filled, nginx had nowhere to send the request | Raise pm.max_children only if the RAM math allows; otherwise the server is undersized |
server reached max_children setting in the log | The ceiling is too low for your concurrency | Re-measure worker size, recalculate, and raise the ceiling within your memory budget |
| Server swapping or OOM kills | pm.max_children times worker size exceeds available RAM | Lower pm.max_children or add RAM; never trade swap for concurrency |
The exact warning PHP-FPM logs when it hits the ceiling looks like this, and it names the current value so you know where you started:
WARNING: [pool www] server reached max_children setting (5), consider raising it
Treat that message as a prompt to measure, not a licence to keep raising the number until the warning stops. A ceiling that silences the warning but sends the box into swap has made the site slower, not faster.
When Tuning Isn’t Enough
I have tuned PHP-FPM pools on servers from 1 GB up to 32 GB, and there is a point on the small end where the real answer is not another config edit. If you have measured your worker size, sized pm.max_children to your RAM, capped pm.max_requests, and the pool still pins at its ceiling during ordinary traffic, you have outgrown the server. Adding RAM or moving to a bigger box is the real fix, and pairing pool tuning with PHP OPcache and object caching buys you the most from whatever hardware you are on.
There is also a point where you simply should not be hand-tuning www.conf at all. If PHP-FPM is not your job and every hour spent on it is an hour not spent on the business, a managed hosting platform like Cloudways sizes and monitors the pool for you, scaling the workers as your traffic grows so a checkout never stalls because a number in a config file was too low. Tuning it yourself is worth doing once so you understand what you are paying someone else to stop worrying about. If you are deciding between manual control and convenience, this comparison of Cloudways vs DigitalOcean for WordPress benchmarks the performance of both.
Comments