Servers & Hosting
PHP-FPM pool sizing: the real reason your server runs out of memory
A server that grinds to a halt under moderate traffic usually is not short of RAM. It has been told it may use several times more than it has.
By Irfan Bhat · · 5 min read
A pattern I see repeatedly: a site is fine for months, traffic grows modestly, and then the server starts freezing under load. Load average climbs into double digits, everything slows to a crawl, and a restart fixes it for a few hours. The instinct is to buy a bigger server. Usually that is not the problem.
What is normally happening is that PHP-FPM has been configured, often by default, to allow more concurrent worker processes than the machine has memory for. Under light traffic you never reach the limit, so nothing is wrong. Under load you do, the kernel starts swapping, and swapping on a busy web server is effectively an outage.
The arithmetic nobody does
PHP-FPM in dynamic mode has a setting called pm.max_children: the maximum number of worker processes it will run at once. Each worker is a full PHP process holding its own memory.
The constraint is simply this:
pm.max_children × average process memory < RAM available to PHP
On a 4 GB server, roughly 1 GB is taken by the OS, MySQL and everything else, call it 3 GB for PHP. If a typical WordPress worker sits at 80 MB, that is about 37 children. If pm.max_children is set to 120 because someone copied a config from a blog post, then at peak the server tries to allocate around 9.6 GB on a 4 GB box.
It does not fail cleanly. It swaps, everything gets slower, requests take longer, more workers spawn to handle the backlog, and the whole thing spirals. That is the freeze.
Measure your actual process memory
Do not guess this number and do not use one from an article, including this one. Measure it on your server, under real traffic:
ps -ylC php-fpm --sort:rss | awk '{sum+=$8; n++} END {print "avg RSS: " sum/n/1024 " MB over " n-1 " procs"}'
Run that a few times during a busy period, not at 3am. You want the realistic average, and it is worth also noting the largest worker. One heavy admin request can be several times the mean.
Then check how much memory is genuinely available:
free -m
# and confirm whether it is already swapping
vmstat 1 5 # watch the si/so columns; sustained non-zero means trouble
Setting the values
With a measured average of 80 MB and 3 GB available to PHP:
pm = dynamic
pm.max_children = 35 ; 3000 MB / 80 MB, rounded down
pm.start_servers = 8
pm.min_spare_servers = 6
pm.max_spare_servers = 12
pm.max_requests = 500 ; recycle workers to contain memory leaks
A note on each:
pm.max_children. the ceiling. Round down. Leaving headroom is deliberate.pm.start_servers, workers ready at startup, so the first burst of traffic is not spent forking.pm.min_spare_servers/max_spare_servers, the idle band. Too narrow and FPM spends its time creating and killing processes.pm.max_requests, recycles a worker after N requests. This does not fix a memory leak, it contains one. Genuinely useful with older extensions.
The log line that tells you directly
PHP-FPM will say when it hits the ceiling. This appears in the FPM error log and it is unambiguous:
WARNING: [pool www] server reached pm.max_children setting (35), consider raising it
Take the advice literally at your peril. Raise it only if the arithmetic allows, if you are already at the memory limit, raising max_children converts a queueing problem into a swapping problem, and queueing is much less damaging. If you genuinely need more concurrency at the same memory, the real answers are to make requests faster or add RAM.
When to use a static pool
On a dedicated application server where PHP is the main workload, pm = static is often better. It allocates all workers at startup and never forks during traffic, which removes a small latency cost and makes memory usage flat and predictable.
pm = static
pm.max_children = 35
The trade-off is that memory is held whether or not it is being used. On a shared box running several sites, dynamic is the safer choice because idle pools give their memory back.
Separate pools per site
If one server hosts several sites, give each its own pool with its own user and its own max_children. Two things follow from this. First, a traffic spike on one site cannot consume every worker and take down the others. Second, each site runs as a distinct system user, so a compromise of one cannot read another's files.
; /etc/php/8.3/fpm/pool.d/site-a.conf
[site-a]
user = site-a
group = site-a
listen = /run/php/site-a.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 15
This is one of the highest-value changes on a multi-site server and it takes about twenty minutes.
A short checklist
- Measure average worker RSS under real load, do not assume it.
- Calculate the memory available to PHP after the OS, database and cache have taken theirs.
- Set
pm.max_childrenfrom that division, rounded down. - Confirm with
vmstatthat the server is not swapping at peak. - Watch the FPM log for the max_children warning, and check the arithmetic before raising it.
- On multi-site servers, split into per-site pools with separate users.
Nine times out of ten this is a configuration fix rather than a hardware one, and it takes an afternoon. If the numbers genuinely do not work out, then the server is undersized, but at least you will know that from measurement rather than from a guess. Related reading: Linux server management.
Written by Irfan Bhat, web developer & server engineer, currently technical support & development at ElySpace. More about me, or get in touch if this raised a question.