PHP-FPM pool tuning, and why pm.max_children is the only number that matters

Most PHP-FPM tuning advice hands you a formula and no way to check it. Here is how to measure the one number the formula guesses at.

Every PHP-FPM tuning guide gives you the same formula:

pm.max_children = (total RAM - other services) / average process size

It is not wrong. It is just that average process size is doing all the work, and nobody tells you how to measure it on your application. So people pick a number that sounds brave, deploy it, and find out at 3am.

What the pool actually does

A pool is a queue and a set of workers. pm.max_children is the number of workers. Every request occupies one worker for its entire lifetime — including the part where it is sitting on a slow database query doing nothing at all.

Set it too low and requests queue. Set it too high and the box swaps, which is far worse than queueing, because a swapping machine gets slower under the load that caused the swap.

Measuring the real process size

ps overstates it. PHP-FPM workers share a lot of memory with the master through copy-on-write, so summing RSS across workers double-counts the shared pages. What you want is PSS, which divides shared pages by the number of processes sharing them:

# Proportional set size, in MB, across every php-fpm worker
grep -H '^Pss:' /proc/$(pgrep -d' -o ' php-fpm | tr ' ' '\n' | head -1)/smaps_rollup

# Or the whole pool at once
ps -o pid= -C php-fpm | while read -r pid; do
    awk '/^Pss:/ {sum += $2} END {printf "%s\t%.1f MB\n", "'"$pid"'", sum/1024}' \
        "/proc/$pid/smaps_rollup" 2>/dev/null
done | sort -k2 -n

Run that under real traffic, not on an idle box. An idle worker that has not yet served a request is not representative of anything.

Setting the pool

With a real number in hand, the config is unremarkable:

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

pm = static
pm.max_children = 24
pm.max_requests = 500

; Log anything slower than five seconds with a full PHP backtrace.
slowlog = /var/log/php-fpm/marcusloo.slow.log
request_slowlog_timeout = 5s

pm = static rather than dynamic is deliberate. On a box that only serves this one application, spawning and reaping workers buys nothing and costs latency at exactly the moment traffic arrives.

pm.max_requests = 500 recycles workers to paper over leaks in extensions you do not control. It is a mitigation, not a fix.

Wiring nginx to it

upstream marcusloo_fpm {
    server unix:/run/php/php8.3-fpm-marcusloo.sock;
}

server {
    listen 443 ssl http2;
    server_name marcusloo.com;
    root /var/www/marcusloo.com;
    index index.php;

    location ~ \.php$ {
        include            fastcgi_params;
        fastcgi_pass       marcusloo_fpm;
        fastcgi_param      SCRIPT_FILENAME $document_root$fastcgi_script_name;

        # Fail fast rather than holding a worker for a minute.
        fastcgi_read_timeout 30s;
    }
}

The part people skip

Watch the queue, not the CPU. PHP-FPM's status page reports listen queue, and any value above zero for a sustained period means requests are waiting for a worker:

Field What it means
listen queue Requests waiting right now
max listen queue The worst it has been since start
slow requests Requests past request_slowlog_timeout

Scraping it is three lines, and worth wiring into whatever already pages you:

<?php
$status = json_decode(file_get_contents('http://127.0.0.1/fpm-status?json'), true);

if (($status['listen queue'] ?? 0) > 0) {
    error_log(sprintf('fpm: %d queued, %d/%d workers busy',
        $status['listen queue'], $status['active processes'], $status['total processes']));
}

Apache's equivalent, if you are on mod_proxy_fcgi rather than nginx:

<FilesMatch \.php$>
    SetHandler "proxy:unix:/run/php/php8.3-fpm-marcusloo.sock|fcgi://localhost/"
</FilesMatch>

<Location "/fpm-status">
    Require local
</Location>

If max listen queue is climbing and memory is fine, raise pm.max_children. If it is climbing and memory is not fine, the answer is not the pool — it is the slow query in slowlog.

Tuning the pool is what you do after the application is fast. It is not a substitute for the application being fast.

NORMAL ~/blog/posts/2026-09-php-fpm-pool-tuning.md php-fpm-pool-tuning utf-8 php Kuala Lumpur 0%
Copied [email protected]