All posts

PHP-FPM pool size for 50 concurrent WordPress users, measured

WordPress 7.1.4 on PHP 8.3-FPM: worker RSS ≈47 MiB, ab -c 50 p95 1439 ms at max_children=5 vs 750 ms at 25. Peak active was 17.

Written by NodeRings Inc.

NodeRings Inc. writes these notes. The company runs the marketplace and the agent that stays on the provider’s side of the hypervisor.

Fifty concurrent clients hit an uncached WordPress 7.1.4 homepage through Nginx and PHP 8.3.6-FPM (ab -k -c 50 -n 500). With pm.max_children = 5, p95 latency was 1439 ms, RPS was 63.66, and php-fpm status showed max active processes: 5 and max children reached: 1. With pm.max_children = 25, p95 fell to 750 ms and RPS rose to 79.62; peak active processes were 17 and children were never exhausted. Raising the ceiling to 50 left peak active at 17 and p95 at 698 ms. Median worker RSS under that load was about 47.2 MiB (48332 KiB).

“50 concurrent users” here means 50 simultaneous HTTP clients on a PHP-rendered URL with no page cache. Cached HTML from LSCache or Nginx fastcgi_cache needs almost no FPM children. Logged-in traffic, /wp-login.php, and cache misses still do.

pm.max_childrenp50 (ms)p95 (ms)RPSPeak activeChildren reached
5640143963.665yes (1)
2557875079.6217no
5060169879.0017no

Failed requests: 0 in all three runs. No 502s in the Nginx error log.

Bar chart. WordPress homepage p95 latency 1439 ms at max_children 5, 750 at 25, 698 at 50.
Same ab -c 50 -n 500 against the uncached homepage. Past peak active (~17), more children barely moved p95.

Size from measured worker RSS

The useful formula is boring and local:

pm.max_children ≈ floor( MiB left for PHP / median worker RSS MiB )

On this lab, workers sat near 47 MiB while serving WordPress. If a 2 GB VPS leaves about 800 MiB for PHP after the OS and MariaDB (idle memory on 2 GB), that is roughly 800 / 47.2 ≈ 16 children. Peak active under the 50-client run was 17. A pool of 5 matches the stock shape from the Nginx WordPress post and queues hard. A pool of 25 covers the measured peak with spare. A pool of 50 wastes RAM on processes this homepage never needed.

Measure RSS yourself under load:

ps -eo rss,cmd | awk '/pool wp-fpm$/ {print $1}'
# median of many samples while ab or real traffic runs

Do not divide by memory_limit (128M here). That is a ceiling, not the working set.

Terminal freeze of worker RSS median 48332 KiB and 800/47.2 ≈ 16 children on 2 GB.
Median RSS under load, then headroom divided by that number. Peak active in this lab landed next to that estimate.

Pool config

Dedicated pool for the site (Ubuntu 24.04 paths):

; /etc/php/8.3/fpm/pool.d/wp-fpm.conf
[wp-fpm]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm-wp.sock
listen.owner = www-data
listen.group = www-data

pm = dynamic
pm.max_children = 25
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10
pm.max_requests = 500
request_terminate_timeout = 60s

pm.status_path = /fpm-status-wp
php_admin_value[memory_limit] = 128M

Point Nginx fastcgi_pass at that socket. Expose status only on loopback:

location ~ ^/(fpm-status-wp)$ {
    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-wp.sock;
}
sudo systemctl reload php8.3-fpm
curl -s -H 'Host: wp-fpm.lab' http://127.0.0.1/fpm-status-wp

Watch active processes, max active processes, and max children reached during a load run.

Terminal freeze of wp-fpm pool.conf with max_children 5 then 25.
Undersized pool for the before row; 25 for the sized row that matched peak active.

How the 50 concurrent run worked

ab -k -c 50 -n 500 -H 'Host: wp-fpm.lab' http://127.0.0.1/

-c 50 keeps fifty clients in flight. -n 500 is enough for stable percentiles on this box. The homepage was Twenty Twenty-Five with no page-cache plugin. PHP 8.3.6-FPM, Nginx 1.24.0, MariaDB on localhost, WordPress 7.1.4.

With five children, every spike of concurrency sat on a full pool (max children reached: 1), so wait time piled into p95. With twenty-five, php-fpm grew to seventeen active workers and stopped. Fifty children never engaged the extra headroom on this URL.

Terminal freeze of ab -c 50 results with max_children 5: p95 1439 ms, max active 5.
Stock-sized pool under 50 clients: children exhausted, p95 over a second.
Terminal freeze of ab -c 50 with max_children 25: p95 750 ms, max active 17.
Sized pool: peak active 17, children still available. max_children 50 left peak active unchanged.
php-fpm status showing max active processes 17 after the sized load run.
Status after the sized run. max active processes is the number your ceiling must clear.

Skip these

Skip thisWhat to do
Setting max_children = 50 because the headline says 50Size from RSS × headroom, then confirm with max active processes
Using memory_limit as worker sizeMeasure RSS under WordPress load
Counting page-cache hits as FPM usersMeasure uncached PHP (or logged-in) paths
Ignoring MariaDB RAMLeave headroom; OOM kills the database first in practice
Skipping pm.status_pathRead peak active and children reached from the status page

Where this fits on a 2 GB VPS

A marketplace Compute Instance at 2 GB can serve this shape of WordPress when max_children sits near the measured peak (teens here) and MariaDB still has room. Fifty simultaneous heavy PHP requests that each hold a worker for a long time need more RAM, or a page cache for anonymous HTML. Build the stack with LEMP or the Nginx WordPress guide, then change the pool with numbers from your own status page.

pm = dynamic is not a free lunch

PHP-FPM pm.max_children is RAM math: roughly (available RAM for PHP) divided by average process size under your theme and plugins. Too low and visitors queue; too high and the OOM killer takes MySQL. pm = dynamic with sensible start_servers / min_spare_servers / max_spare_servers fits bursty WordPress; ondemand saves idle RAM on quiet sites at the cost of spawn latency. Watch php-fpm slow-log and listen.backlog when concurrency climbs. The 50-user lab number on this page is a measured starting point for that theme, not a universal formula.

FAQ

ondemand vs dynamic? ondemand spawns children on request and can save idle RAM; it adds spawn latency. This lab used dynamic. For a busy public site, dynamic (or static at the measured peak) is the usual choice.

Does object cache change max_children? Redis/Valkey object cache shortens PHP work; it does not remove the need for a child per in-flight PHP request. Peak active can drop a little; re-measure.

What about /wp-login.php? Often heavier than the homepage. Re-run ab against that URL if login is your real concurrency story.

pm.max_requests? Recycles workers to limit leaks. 500 is a reasonable starting point; it is unrelated to the 50-client ceiling.

The number on the tin is concurrent PHP work. Browsers parked on cached HTML barely count. Measure worker RSS, set max_children from RAM left for PHP, then prove it with ab -c 50 and max active processes on pm.status_path.