Tuning PHP-FPM Process Pools by Workload Type, Not by Copy-Paste

Most PHP-FPM advice on the internet is a single number — “set pm.max_children to 25″ — copied between articles of wildly different workloads. That number is wrong for a WooCommerce checkout and wrong for a mostly-cached brochure site, and in both cases the failure mode is silent: some requests queue at the pool boundary and time out while CPU sits at 40%. This guide sizes a pool from the workload up. If you are still deciding how much machine to buy, the trade-off between shared hosting and a VPS is exactly the sizing decision this article assumes you have already made.

Start with the memory ceiling, not with a child count

Each PHP-FPM child holds a resident footprint. That footprint is the true constraint on a VPS, because when children oversubscribe RAM the kernel either swaps (killing latency) or the OOM killer removes a child mid-request (killing correctness). Measure it properly on a live system rather than guessing from top:

# Average RSS of the 5 largest PHP-FPM workers, in MB
ps --no-headers -o rss,cmd -C php-fpm8.3 \
  | sort -rn | head -5 \
  | awk '{ sum += $1; n++ } END { printf "avg %.1f MB\n", sum/n/1024 }'

Then subtract what everything else needs from total RAM:

# Budget example on a 4 GB VPS
#   OS + systemd + sshd .......... 250 MB
#   nginx ......................... 120 MB
#   MySQL/InnoDB buffer pool ..... 1200 MB
#   Redis ......................... 200 MB
#   --------------------------------------------
#   Available for PHP-FPM ........ 2230 MB
#   Average worker RSS ........... 45 MB
#   Safe max_children ............ 2230 / 45  ~= 49
#   Apply a 20% safety margin .... 39

Thirty-nine is your hard ceiling. The process manager you choose determines whether you actually approach it.

Choose the process manager from the traffic shape

ManagerBehaviourBest forAvoid when
staticFixed N children, always residentSteady high traffic; memory dedicated to PHPTraffic varies 5x between day and night
dynamicScales between min and max spawnsMost production LEMP stacks with predictable peaksVery spiky traffic causes spawn churn
ondemandSpawns on demand, reaps when idleLow traffic, many sites, small RAM VPSHigh request rate — spawn overhead dominates

The critical detail: ondemand has a hard request-rate ceiling imposed by fork cost, roughly 100–300 spawns per second before it becomes the bottleneck. On a busy site it will produce exactly the symptom people misattribute to PHP being slow — first-byte latency that grows with concurrency.

Cached content site (WordPress + Redis + Nginx FastCGI cache)

When 90%+ of requests are served from cache, PHP only handles uncached pages, admin traffic, and cron. You need few children but each should be stable.

pm = dynamic
pm.max_children = 12
pm.start_servers = 4
pm.min_spare_servers = 3
pm.max_spare_servers = 8
pm.max_requests = 500

Uncached database-driven application (SaaS, custom CMS)

Every request touches PHP and the database. Concurrency maps directly to DB connections, so this is where pool sizing and database max_connections must be reconciled — otherwise you trade a PHP queue for a MySQL “too many connections” error.

pm = dynamic
pm.max_children = 25
pm.start_servers = 8
pm.min_spare_servers = 8
pm.max_spare_servers = 20
pm.max_requests = 300
pm.status_path = /fpm-status

Check the sum: 25 PHP children × 1 persistent connection each = 25 connections. MySQL’s default max_connections of 151 is fine. If you run multiple pools, add them up.

Batch and queue workers (Laravel Horizon, cron processors)

Workers do not serve HTTP and should not share a pool with web traffic at all. Give them their own pool with static, a number matching your CPU core count, and long timeouts that would be unacceptable for web requests.

[www]
pm = static
pm.max_children = 4
pm.max_requests = 1000
request_terminate_timeout = 900

[workers]
pm = static
pm.max_children = 2
pm.max_requests = 50
request_terminate_timeout = 3600

Two separate pools under one master. Without this split, one long-running import job can hold half your web workers hostage — a failure pattern that shows up as random 504s during scheduled tasks.

Recycling: the settings that cause slow leaks and slow first requests

pm.max_requests restarts a child after N requests. It exists to bound memory leaks and fragmentation, but it has a real cost: every restart discards the opcode cache warm-up for that worker and forces fresh database connections.

  • 300–500 for typical WordPress. Catches slow leaks without excessive churn.
  • 1000+ for stable applications where you have measured that RSS is flat after 10k requests.
  • 0 (never) only if you monitor RSS continuously with alerting on growth.

Pair this with OPcache settings that survive the pattern: opcache.validate_timestamps=1 with revalidate_freq=60 during development, and validate_timestamps=0 in production deployments where you explicitly reset OPcache. A worker that revalidates the filesystem on every request after a max_requests restart multiplies the recycle cost.

Diagnose the pool before you tune it

Enable the status page and read it during a load test, not from a graph someone posted online:

; In the pool config
pm.status_path = /fpm-status

# In the nginx server block
location = /fpm-status {
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
}

Then hit it under load:

curl -s http://127.0.0.1/fpm-status?full

The four numbers that matter:

  1. max children reached — if non-zero, requests waited for a free worker. Your effective concurrency is too low for the arrival rate.
  2. listen queue — backed-up connections waiting for the socket. Sustained non-zero means the same thing, more severely.
  3. idle processes — if this is at min_spare_servers constantly while the queue grows, min_spare is too low.
  4. active processes relative to max_children — a steady 90%+ means you are at the ceiling.

Cross-check with worker-level timing to see whether time is going to PHP itself or to something it waits on:

; Slow request logging, 2 seconds or worse
request_slowlog_timeout = 2s
slowlog = /var/log/php-fpm/slow.log
request_terminate_timeout = 60s

The slowlog gives you a stack trace of where the request was when it exceeded the threshold. If those traces consistently point at database calls, raising max_children makes things worse: you are queuing on MySQL instead of on PHP, and you have added load to the overloaded layer. The correct sequence is to fix the query pattern first, then the pool.

A sizing method you can reuse for any workload

# 1. Measure worker RSS under real load
ps --no-headers -o rss,cmd -C php-fpm8.3 | sort -rn | head -5 \
  | awk '{ s+=$1; n++ } END { printf "%.0f MB avg\n", s/n/1024 }'

# 2. Compute the budget (total RAM minus everything else, minus 20%)
free -m

# 3. Derive the hard ceiling
#    max_children = budget_MB / avg_worker_MB

# 4. Set start/min_spare to roughly 1/3 of max
#    (so a cold pool is warm before the traffic arrives)

# 5. Load-test and read /fpm-status
ab -n 5000 -c 40 https://example.com/

Run this once per application, note the numbers in your runbook, and revisit only when the workload changes shape. The pool that serves 40 concurrent users on a cached WooCommerce front end is not the pool that serves a 200-connection API, and treating them as the same configuration is how a VPS that benchmarks well ends up producing 504s in production.

If your measurements keep landing on the same conclusion — the process pool needs more headroom than the current plan’s RAM allows — that is a sizing signal rather than a tuning signal. Users who want the OS and pool layer pre-configured are usually better served by a managed stack; Cloudways’ managed PHP hosting ships with tuned defaults you can still override. If you would rather keep root and tune it yourself, InterServer’s VPS line gives you the raw configuration surface plus a published memory allotment, which is what the arithmetic above requires.

For more on the surrounding stack, see our low-memory PHP-FPM tuning guide — it covers the OPcache and nginx interaction that determines how much of your pool capacity is actually usable.

Leave a Reply