Calculating pm.max_children for PHP-FPM instead of guessing

Published on 22 min read

The message server reached pm.max_children does not mean you should double the value. Measure, calculate, choose the process manager mode, and then prove that the value really fits.

Sooner or later this line shows up in the PHP-FPM log, and it comes with a piece of advice attached:

WARNING: [pool www] server reached pm.max_children setting (5), consider raising it

The advice is not wrong, only incomplete. pm.max_children is the one setting in PHP-FPM where a value that is too high is more dangerous than a value that is too low. Too low costs waiting time and, in the worst case, a 502. Too high costs the memory of the entire server, and then the kernel picks for itself which process it terminates. In practice that process is not PHP, it is the database.

This guide shows the path from guessing to calculating: measure how much memory one worker process really needs, determine the budget, choose the process manager mode and then prove that the value is correct.

Everything here applies to Debian 13 (trixie), Debian 12 (bookworm), Ubuntu 24.04 LTS and Ubuntu 22.04 LTS. The commands are written for use as root; as a regular user, put sudo in front of them. Replace the PHP version number everywhere with the one on your system.

SystemPHPServicePool fileFPM log
Debian 13 (trixie)8.4php8.4-fpm/etc/php/8.4/fpm/pool.d/www.conf/var/log/php8.4-fpm.log
Debian 12 (bookworm)8.2php8.2-fpm/etc/php/8.2/fpm/pool.d/www.conf/var/log/php8.2-fpm.log
Ubuntu 24.04 LTS8.3php8.3-fpm/etc/php/8.3/fpm/pool.d/www.conf/var/log/php8.3-fpm.log
Ubuntu 22.04 LTS8.1php8.1-fpm/etc/php/8.1/fpm/pool.d/www.conf/var/log/php8.1-fpm.log

A look into the directory shows which versions are installed. On servers that have been through a distribution upgrade there are often two:

ls /etc/php/
ls /etc/php/*/fpm/pool.d/

What pm.max_children actually limits

The value does not limit visitors and it does not limit connections. It limits the number of PHP requests that are running at the very same moment. A request that takes 80 milliseconds occupies one worker process for 80 milliseconds and then releases it again. Almost everything else follows from that:

  • Heavy traffic needs few processes, as long as the scripts are fast. 40 requests per second at 80 milliseconds each work out to a little over three concurrent requests, not 40.
  • One slow spot turns the whole calculation upside down. A call to a third party API without a timeout of its own keeps a process occupied for five seconds even though that process is doing nothing. Five such calls per second tie up 25 processes permanently.
  • Waiting connections occupy no process. A keep-alive connection to nginx costs one file descriptor, but no worker process.

Once every process is busy, new requests wait in the accept queue of the socket. Its size is set in the pool file under listen.backlog, 511 by default, and the kernel caps it further through net.core.somaxconn, which is 4096 on all four systems:

sysctl net.core.somaxconn

As long as the queue holds, the visitor only notices longer loading times. Once the queue is full as well, the kernel refuses the connection, nginx logs 11: Resource temporarily unavailable, and the visitor gets a 502. To tell this apart from the other possible causes: fixing nginx 502 Bad Gateway.

One detail causes a lot of confusion: the warning appears once per saturation phase, not once per affected request. FPM sets an internal flag and only clears it when a process becomes free again. A single line can stand for an entire peak hour. Counting lines therefore understates the problem. The honest measurement is the max children reached counter on the status page, which is queried further down.

The way back, before you change anything

Two things can go wrong here, and both of them hit production.

First: FPM no longer starts. A systemctl reload sends signal USR2 to the master process, which then restarts itself with the new configuration. If that configuration is invalid, initialization aborts and the master process exits. A running instance turns into a dead one. So without exception: test first, reload second:

php-fpm8.2 -t

On success the output ends with test is successful.

Second: the value is too high. The server does not break right away, it breaks at the next load peak, and it breaks thoroughly enough that an SSH login hangs or never gets established at all. For that case you need a second way onto the server.

On the KernelHost KVM root servers and dedicated servers that second way is the VNC console in the customer panel. It attaches to the virtualization layer, or to the machine itself, and stays reachable even when the SSH service has stopped answering for lack of memory. Log in through it once beforehand. An escape route that you try for the first time during the emergency is not an escape route.

Also make a copy of the pool file, with a timestamp, so that a second attempt does not overwrite the first copy:

mkdir -p /root/backups
cp -a /etc/php/8.2/fpm/pool.d/www.conf /root/backups/www.conf.$(date +%F-%H%M)
ls -l /root/backups/

The way back is then three lines long:

cp -a /root/backups/www.conf.2026-09-03-1015 /etc/php/8.2/fpm/pool.d/www.conf
php-fpm8.2 -t
systemctl reload php8.2-fpm

A guard rail in case your calculation is off

A memory limit on the systemd unit makes sure that a miscalculation hits a PHP worker process and not the database: the kernel then kills inside the control group of FPM instead of hunting for the biggest process system-wide.

mkdir -p /etc/systemd/system/php8.2-fpm.service.d
cat > /etc/systemd/system/php8.2-fpm.service.d/memory.conf <<'EOF'
[Service]
MemoryHigh=1500M
MemoryMax=2G
EOF
systemctl daemon-reload
systemctl restart php8.2-fpm

Check that it actually took effect:

systemctl show php8.2-fpm -p MemoryHigh -p MemoryMax

MemoryHigh throttles and reclaims, MemoryMax is the hard limit. All four systems use the unified control group hierarchy, so the values take effect immediately. This is a guard rail, not a replacement for the calculation: when the limit is reached, the affected visitor still sees an error, it is just not the whole server that goes down. On putting such drop-in files in the right place: creating a systemd service.

And the third rule, the one that needs no command at all: one change at a time. If you touch the process manager mode, pm.max_children and the spare values all at once, you will not know afterwards which of them made the difference.

Taking stock: which pool applies

Every pool has its own pm.max_children, and what counts for memory is the sum across all pools:

grep -n "^pm" /etc/php/*/fpm/pool.d/*.conf

As shipped, all four systems carry the same values:

pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

These are not recommendations, they are placeholders that let PHP start even on a very small test machine. On top of that there is a global upper limit across all pools:

grep -n "^process.max" /etc/php/*/fpm/php-fpm.conf

If the output stays empty, the line is commented out and the default of 0 applies, which means no global limit at all. On servers with many pools, this one line is what keeps the sum from blowing through the memory as soon as several pools are under load at the same time.

In the end what matters is not the file but what FPM makes of it. The -tt switch prints the configuration fully resolved, including every default value that appears in no file at all:

php-fpm8.2 -tt 2>&1 | grep -E "^\[|pm |pm\.|listen ="

Later on, this output is also the proof that a change has actually arrived.

Measuring the memory a worker process needs

This is where most guides get sloppy. Three numbers are regularly mixed up:

  • memory_limit, 128M by default under FPM: an upper limit per request, not consumption. A script that needs 12 MB still needs only 12 MB with 512M configured.
  • RSS: everything currently held in memory, including the shared OPcache and the shared libraries. Adding up RSS across 20 processes counts the same OPcache twenty times.
  • PSS: shared pages are divided by the number of processes using them. The only one of the three numbers that can sensibly be summed up.

For the calculation you need PSS. The kernel provides it ready-aggregated in /proc/<pid>/smaps_rollup, readable as root:

pgrep -f "php-fpm: pool www" | while read -r p; do
  awk '/^Pss:/ {print $2}' "/proc/$p/smaps_rollup"
done | awk '{s+=$1; n++} END {
  if (!n) { print "no worker processes found"; exit }
  printf "Processes: %d   Total: %.0f MiB   Average: %.1f MiB\n", n, s/1024, s/1024/n
}'

For comparison, the same pool measured through RSS:

ps -eo pid,rss,args --sort=-rss | grep '[p]hp-fpm' | head

Depending on the size of the OPcache, the RSS total is considerably higher. Calculating with it gives you a pm.max_children that is too small and buys you waiting time that nobody needed.

Two conditions decide whether the measurement is worth anything. Measure under real load, not right after a restart: a freshly forked process is cheap, because at first it only shares the memory pages of the master process, and it becomes expensive with the first requests. And do not go by the average alone: if the average is 60 MiB and the largest process is 190 MiB, do not calculate with 60.

The second source: the FPM access log

On request, FPM logs the peak memory and the runtime of every single request. Both lines sit commented out in the pool file:

access.log = /var/log/php8.2-fpm.access.log
access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{mili}d %{kilo}M %C%%"

The spelling %{mili}d is correct as it stands, it comes unchanged from the file that ships with the package and returns the runtime in milliseconds, while %{kilo}M returns the peak memory in kilobytes. After that, test, reload and check whether the file appears:

php-fpm8.2 -t
systemctl reload php8.2-fpm
ls -l /var/log/php8.2-fpm.access.log

Let the log run for a full day so that the peak hours are included. Then evaluate the peak memory as quantiles:

awk '{print $(NF-1)+0}' /var/log/php8.2-fpm.access.log | sort -n | awk '{v[NR]=$1} END {
  if (!NR) exit
  p=int(NR*0.95); if (p<1) p=1
  printf "Requests: %d   Median: %d kB   p95: %d kB   Maximum: %d kB\n", NR, v[int((NR+1)/2)], v[p], v[NR]
}'

The same evaluation for the runtime, which you need in a moment for the second calculation, takes the column before it:

awk '{print $(NF-2)+0}' /var/log/php8.2-fpm.access.log | sort -n | awk '{v[NR]=$1} END {
  if (!NR) exit
  p=int(NR*0.95); if (p<1) p=1
  printf "Median: %.0f ms   p95: %.0f ms   Maximum: %.0f ms\n", v[int((NR+1)/2)], v[p], v[NR]
}'

Two caveats. First, the field positions depend on the format line shown above, because that line ends with runtime, memory and CPU share. If you change access.format, you have to adjust them. Second, %{kilo}M is the memory accounting of PHP itself and therefore a lower bound: program code, extensions and the memory that the C library does not hand back immediately after a request are all missing from it. That is why the formula uses PSS. The access log is there to find the outlier scripts that make a process permanently large.

Switch the log off again afterwards, or set up rotation for it. The rule that ships with the package covers the error log only, and a full disk produces symptoms that have nothing to do with PHP-FPM any more: freeing up space on a full Linux disk.

The calculation

There are two numbers: an upper limit from the memory (RAM) and a demand figure from the load. The correct value is the smaller of the two.

Upper limit from the available RAM

pm.max_children = budget for PHP  /  PSS per worker process

The budget is not the total RAM:

free -m

The available column already accounts for the cache that can be reclaimed, but it does not include the memory your worker processes are holding right now. So the budget is available plus the measured PSS total, minus a reserve. Around 20 percent of total memory has proven a good reserve, but never less than 512 MB. It absorbs everything that shows up in no measurement: a growing buffer pool in the database, a backup, a package upgrade, a bulk import at the worst possible moment.

Total RAMPermanently in useReserveBudget for PHPPSS per processpm.max_children
4 GB1.5 GB0.8 GB1.7 GB60 MB29
8 GB3.0 GB1.6 GB3.4 GB80 MB43
16 GB6.0 GB3.2 GB6.8 GB110 MB63

Always round down. One process more gains you nothing, one process too many can cost you everything.

Demand from the load

required processes = requests per second  ×  average runtime in seconds

The requests per second come from the FPM status page: accepted conn divided by start since gives the average since the last start. The runtime comes from the evaluation above, and specifically the p95 value rather than the median, otherwise you are planning for the quiet afternoon.

An example: 40 requests per second at peak, a p95 runtime of 300 milliseconds, which works out to 12 concurrent requests, or roughly 20 to 25 with an allowance for short spikes. If the upper limit is 43, enter the demand figure and leave the remaining memory where it does more good, namely in the database cache.

If the demand comes out above the upper limit, do not enter it even so. In that case either the RAM is too small, or the scripts are too slow, or requests are going through PHP that could be served as a static file or out of a cache. All three causes can be fixed, a value that is too high cannot: it only moves the moment of the outage.

dynamic, ondemand or static

The process manager mode decides when processes are created and when they disappear. The upper limit pm.max_children applies in all three cases.

ModeProcesses at startBehaviorMemorySuits
dynamicpm.start_serverskeeps between min_spare and max_spare processes idle and ready, forks up to max_children on demandfluctuates with the loadthe normal case: one to a few pools, varying load
ondemandnonestarts a process only when a request arrives, terminates it again after pm.process_idle_timeoutlowest while idlemany pools on one server, sites with little traffic
staticpm.max_childrenexactly that number, permanently, with no forking and no terminating during operationconstant at the maximuma single pool on hardware set aside for it, steady load

dynamic is the default and the right choice for the normal case. It costs a little CPU time for the forking and requires the four values to fit together.

ondemand saves a noticeable amount of memory while idle when a dozen pools for rarely visited sites sit on one machine. The price is the first request after a quiet spell, which has to wait for a process to be forked. pm.process_idle_timeout controls how long an idle process survives (ten seconds by default) and takes effect in this mode only. Note as well: here the saturation warning says max_children without the pm. prefix. Searching for the familiar wording finds nothing.

static is honest: whatever you enter is allocated immediately and permanently, and in return there are no surprises left under load. It makes sense when PHP is the main consumer on the machine. If PHP shares the memory with a database, static takes away that database's ability to hold more cache for a while.

Whichever mode you pick, pm.max_requests is worth a look. It is 0 by default and therefore unlimited. A value such as 500 replaces every worker process after 500 requests. Against slowly growing memory usage caused by a sloppy extension this is effective and cheap, because the OPcache stays shared and is not rebuilt. Do not set the value to 20 though, or FPM spends its time forking.

start_servers and the spare values

These three values only take effect with pm = dynamic and decide how quickly FPM reacts to a load spike:

  • pm.min_spare_servers: this many idle processes FPM keeps ready at minimum, the buffer for spikes. Too low means every spike has to wait for a fork first.
  • pm.max_spare_servers: this many idle processes FPM tolerates at most. Without that limit, FPM would keep every process after a spike, and with them their memory.
  • pm.start_servers: this many processes exist immediately after the start.

At startup FPM checks four conditions and refuses to run if one of them is violated: both spare values must be greater than zero, neither of them may be greater than pm.max_children, max_spare must not be smaller than min_spare, and start_servers has to sit between the two. If pm.start_servers is missing entirely, FPM works it out itself and logs:

NOTICE: [pool www] pm.start_servers is not set. It's been set to 3.

The formula behind that is min_spare + (max_spare - min_spare) / 2, in other words the midpoint between the two spare values. As a starting point, this works well in practice:

pm.max_children       = 40
pm.start_servers      = 10
pm.min_spare_servers  = 6
pm.max_spare_servers  = 16

Easy to overlook: idle processes take up memory too. The budget has to carry pm.max_children, whereas everyday usage roughly matches max_spare plus the active processes. A high max_spare keeps that memory occupied at three in the morning as well.

How swap fits into this

It is tempting to count swap space into the calculation. Do not do it. A worker process whose data sits on disk answers orders of magnitude more slowly and therefore stays occupied longer. The number of concurrent requests rises, FPM forks more processes, and those push even more memory out. This feedback loop is the reason why an oversubscribed server does not get gradually worse but tips over within a few minutes.

What swap does deliver is a buffer for the failure case. Without it, oversubscription ends abruptly with the kernel terminating a process. With it, you first get a slow server and therefore a window in which to intervene. The rule is: swap yes, but calculate pm.max_children exclusively against the real RAM, never against the sum of RAM and swap.

Two places tell you whether you are already swapping. free -m shows how much is in use but says nothing about the activity, because a page that was swapped out once and never needed again is harmless. What is meaningful are the si and so columns, swap in and swap out per second:

free -m
vmstat 1 5

Values above zero over a longer period mean the server is working against the disk. If your kernel ships the pressure indicator, that one is even more direct, because it does not say how much was swapped out but how long processes had to wait because of it:

cat /proc/pressure/memory

If the file does not exist, the feature is switched off in the kernel and you stay with vmstat. How to create swap, make the entry permanent and set vm.swappiness appropriately is covered in the article setting up swap and preventing out of memory crashes.

Set too high: the OOM killer instead of a queue

Suppose you enter 200 so that the warning finally goes away. While the server is idle nothing happens, the page loads, everything looks solved. At the next rush FPM really does fork up to 200 processes, each one grows to its true size with the first requests, free memory drops, the kernel first throws away the file cache (which makes the database slower and its queries longer), then it starts swapping, and then the OOM killer steps in.

It picks its victim by memory usage, and the largest single process on a web server is not a PHP worker process with 80 MB, it is the database with its buffer pool:

dmesg -T | grep -iE "out of memory|oom-kill"
journalctl -k --since "24 hours ago" | grep -i "out of memory"

The two lines that matter look like this:

php-fpm8.2 invoked oom-killer: gfp_mask=0x1100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
Out of memory: Killed process 1234 (mariadbd) total-vm:2891234kB, anon-rss:1783456kB,
file-rss:0kB, shmem-rss:0kB, UID:107 pgtables:4096kB oom_score_adj:0

Trigger and victim are two different processes here, and that is exactly what makes troubleshooting unpleasant: the inbox holds an alert about a crashed database, and nobody thinks of the PHP configuration from two days ago. Depending on the database, the brackets contain either mariadbd or mysqld.

If it does hit a worker process, FPM reports that itself, and with a memory limit in place it appears in the journal as well:

WARNING: [pool www] child 1234 exited on signal 9 (SIGKILL) after 3612.472183 seconds from start
php8.2-fpm.service: A process of this unit has been killed by the OOM killer.

The difference between these two failure modes is the real point of this article. A pm.max_children that is too low produces a queue: measurable, documented in the log, traceable to one value, and the server stays reachable. One that is too high produces a terminated process in a place you did not choose, in a service that may well not come back on its own. Waiting time is an operating state, an OOM event is an incident.

Common errors and how to fix them

WARNING: [pool www] server reached pm.max_children setting (5), consider raising it: at one point in time every worker process was busy. The line appears once per saturation phase, so a single line can mean an hour of full load. Raise the value only after you have measured PSS and determined the budget.

WARNING: [pool www] server reached max_children setting (5), consider raising it: the same situation under pm = ondemand, worded without the pm. prefix.

ALERT: [pool www] pm.min_spare_servers and pm.max_spare_servers cannot be greater than pm.max_children, followed by ERROR: failed to post process the configuration and ERROR: FPM initialization failed: the most common case when lowering pm.max_children. Going from 50 down to 8 and leaving pm.max_spare_servers = 20 in place gives you a service that no longer starts. All four values belong together and have to be changed together.

ALERT: [pool www] pm.start_servers must not be less than pm.min_spare_servers and not greater than pm.max_spare_servers: pm.start_servers sits outside the range. Correct the value or remove the line, then FPM works it out itself.

PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes): this is memory_limit and has nothing to do with pm.max_children. A single script requested more than PHP allows per request. A higher pm.max_children changes nothing about that, and conversely a lower memory_limit does not shrink the memory a worker process needs, it only aborts scripts earlier. For planning purposes it still holds: memory_limit is the ceiling on what one process is allowed to request.

connect() to unix:/run/php/php8.2-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream in the nginx error log: every process busy and the accept queue full on top of that. A higher listen.backlog only postpones this, the cause lies in the number of processes or in the runtime of the scripts.

WARNING: [pool www] child 1234 exited on signal 9 (SIGKILL) after 3612.472183 seconds from start: the process was killed hard from the outside, as a rule by the OOM killer. Here the value is too high, not too low. Cross-check in the kernel log.

"I raised the value and nothing changes." Almost always the wrong file was edited: a second PHP version under /etc/php/, a second pool, or a copy in pool.d/ that is never read at all. What counts is which socket nginx talks to and which pool listens on it:

grep -Rn "fastcgi_pass" /etc/nginx/
grep -n "^listen *=" /etc/php/*/fpm/pool.d/*.conf
php-fpm8.2 -tt 2>&1 | grep "pm.max_children"

"After the reload the site is gone." A reload restarts FPM with the new configuration, and if that configuration is invalid, the master process exits. Restore the copy from /root/backups and get into the habit of running php-fpm8.2 -t before every reload.

How to tell that the value is right

"The warning is gone" is not proof. With a value of 500 it is gone as well, right up to the first OOM event. Five checks carry real weight.

First, the FPM status page. Add pm.status_path = /status to the pool file, test and reload, then query the socket directly, bypassing nginx completely. That way the page is not reachable from the internet:

apt-get install -y libfcgi-bin
SCRIPT_NAME=/status SCRIPT_FILENAME=/status REQUEST_METHOD=GET \
  cgi-fcgi -bind -connect /run/php/php8.2-fpm.sock

Four lines of the output carry the answer:

FieldMeaningTarget value
max children reachedhow often the upper limit has been reached since the start0
max listen queuelongest queue observed on the socket0
max active processeshighest number of simultaneously active processeswell below pm.max_children
slow requestsrequests above request_slowlog_timeout0 if possible

This only becomes meaningful after a full week that covers every peak period. If max active processes then sits at 12 while pm.max_children is set to 43, you have headroom and can put the reserve to use elsewhere.

Second, the memory under load, not at night but at peak. available has to stay clearly above zero, and the si and so columns should read zero:

free -m
vmstat 1 5

Third: no OOM event. This check is the most important one, because it rules out the worst failure mode. Empty output is the desired result:

journalctl -k --since "7 days ago" | grep -i "out of memory"

Fourth, the sum across all pools. On a server hosting several sites it is not the individual value that counts but the sum, multiplied by the PSS per process. It has to fit into the budget even when all pools are under load at the same time:

php-fpm8.2 -tt 2>&1 | grep "pm.max_children"

Fifth: it survives a reboot. The point that gets skipped most often. A value in a file that is never read only becomes obvious at the next reboot, and that rarely happens while you are watching:

systemctl is-enabled php8.2-fpm
systemctl restart php8.2-fpm
php-fpm8.2 -tt 2>&1 | grep "pm.max_children"

After that, reboot the server once in a controlled way while you are still watching. On the KernelHost KVM root servers and dedicated servers you trigger that reboot in the customer panel and follow the boot process through the VNC console, even while the web service is not answering yet. The servers are located in the maincubes datacenter in Frankfurt am Main.

Short checklist

  1. Copy of the pool file in /root/backups, access through the VNC console tried out once.
  2. Measure PSS per worker process under real load, not after a restart, and do not calculate with RSS.
  3. Determine the budget: available plus the current PSS total, minus a deliberately chosen reserve.
  4. Work out the upper limit, check it against the demand from requests per second times p95 runtime, take the smaller value, round down.
  5. Choose the process manager mode and adjust the spare values together with pm.max_children.
  6. php-fpm8.2 -t, then systemctl reload, then php-fpm8.2 -tt as proof that the value is loaded.
  7. A week later, check max children reached, max listen queue and the kernel log.

Frequently asked questions

How do I calculate pm.max_children correctly?
Through two numbers, and the smaller value wins. The upper limit is the budget for PHP divided by the proportional memory (PSS) of one worker process. The budget is the available column from free -m, plus the memory the running worker processes are holding right now, minus a reserve of around 20 percent of total memory. The second number is the demand: requests per second at peak multiplied by the p95 runtime in seconds. If the upper limit comes out at 43 and the demand at 22, enter 22 and leave the rest of the memory to the database. Always round down.
Why is a value that is too high more dangerous than one that is too low?
A value that is too low produces a queue. Requests wait in the accept queue of the socket, the site gets slower, the server stays reachable and the log tells you exactly what is going on. A value that is too high produces a memory shortage under load, and then the kernel picks a victim itself, by memory usage. The largest process on a web server is usually the database with its buffer pool, not a PHP worker process. So you are trading measurable waiting time for an unannounced outage of a different service.
Why should I calculate with PSS and not with RSS?
RSS also contains the shared memory regions, above all the OPcache and the shared libraries. Adding up RSS across 20 worker processes counts the same OPcache twenty times and produces a memory requirement that is far too large, and therefore a pm.max_children that is needlessly small. PSS divides shared pages by the number of processes using them and can therefore be summed up correctly. As root, the value sits in /proc/PID/smaps_rollup on the Pss line.
When do I use dynamic, when ondemand and when static?
dynamic is the default and the right choice for the normal case: one to a few pools with varying load. ondemand pays off when many pools for rarely visited sites sit on one machine, because while idle no processes exist there at all. The price is the first request after a quiet spell, which waits for a process to be forked. static fits when PHP is the main consumer on the machine and the load is steady: the memory is allocated immediately and permanently, and in return there are no surprises left under load. If PHP shares the server with a database, static takes away that database's ability to hold more cache for a while.
Can I include swap in the calculation?
No. A worker process whose data sits on disk answers orders of magnitude more slowly and therefore stays occupied longer. The number of concurrent requests rises, FPM forks more processes, and those push even more memory out. That is why an oversubscribed server tips over within a few minutes instead of getting gradually slower. Swap still makes sense, but as a buffer for the failure case: it gives you a window in which to intervene before the kernel terminates a process. The calculation is done exclusively against the real RAM.
I raised pm.max_children and nothing changes. What is causing that?
Almost always the wrong file was edited. On servers with several PHP versions under /etc/php/ or with several pools, only the pool whose socket nginx actually talks to counts. Compare fastcgi_pass from the nginx configuration with the listen lines of the pool files. What FPM has really loaded is shown by php-fpm8.2 -tt, whose output contains the fully resolved configuration including every default value.
After lowering pm.max_children, PHP-FPM no longer starts. What happened?
The spare values are probably still set to the old, higher numbers. FPM refuses to start with the message that pm.min_spare_servers and pm.max_spare_servers cannot be greater than pm.max_children, followed by FPM initialization failed. Adjust all four values together: both spare values must be greater than zero and at most as large as pm.max_children, max_spare must not be smaller than min_spare, and pm.start_servers has to sit between the two.
Does memory_limit have anything to do with pm.max_children?
Only indirectly. memory_limit is the upper limit that PHP enforces per request, 128M by default under FPM. The message about exhausted memory (Allowed memory size exhausted) concerns a single script and does not get any better with a higher pm.max_children. Conversely, a lower memory_limit does not shrink the memory a worker process needs, it only aborts scripts earlier. The connection lies in the planning: if you set memory_limit to 1024M, in the worst case you have to reckon with that size per process as well.

PHP-FPM pm.max_children RAM Debian Ubuntu nginx OOM killer Server optimization