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
| Manager | Behaviour | Best for | Avoid when |
|---|---|---|---|
static | Fixed N children, always resident | Steady high traffic; memory dedicated to PHP | Traffic varies 5x between day and night |
dynamic | Scales between min and max spawns | Most production LEMP stacks with predictable peaks | Very spiky traffic causes spawn churn |
ondemand | Spawns on demand, reaps when idle | Low traffic, many sites, small RAM VPS | High 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:
max children reached— if non-zero, requests waited for a free worker. Your effective concurrency is too low for the arrival rate.listen queue— backed-up connections waiting for the socket. Sustained non-zero means the same thing, more severely.idle processes— if this is atmin_spare_serversconstantly while the queue grows,min_spareis too low.active processesrelative tomax_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
You must be logged in to post a comment.