# PHP-FPM process management in production

> pm static, dynamic and ondemand, the memory ceiling nothing enforces, and how to size a pool from measurements rather than guesses.

- Published: 2026-08-20
- Tags: php-fpm, memory, profiling
- Source: https://elephantphp.com/blog/php-fpm-process-management-in-production/
- Language: en-US
- Author: Alden Pike

---
Twenty-four PHP-FPM workers, each holding about 105 MB after one request that
built a large array, came to 2,515 MB of resident memory on a pool configured
with `pm.max_children = 24`. FPM logged nothing about it, because nothing in FPM
performs that multiplication.

## Method

PHP 8.5.9 fpm-fcgi, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop
running macOS with 24 GB of RAM and 12 logical cores, 8 of them performance
cores. Not quiesced. No FastCGI client was installed, so I wrote a small one
that opens a set number of concurrent connections and records each request from
send to `FCGI_END_REQUEST`.

This is the baseline pool, written with the socket path a reader would actually
use. The pool I measured needed a shorter one: `sun_path` caps a unix socket at
104 bytes and my scratch directory overran it, which FPM reports by truncating
the path rather than refusing to start. Only the settings named in each section
below varied, and every configuration was restarted and warmed before it was
measured:

```ini
[www]
listen = /run/php-fpm.sock
listen.backlog = 511
pm = static
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 0
pm.process_idle_timeout = 10s
pm.status_path = /status
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.jit] = disable
php_admin_value[opcache.file_update_protection] = 0
```

The three spare-server settings are inert under `pm = static`. Under
`pm = dynamic` each governs a different moment — `pm.start_servers` where the
pool begins, `pm.min_spare_servers` where it stops climbing under load, and
`pm.max_spare_servers` where it settles back once load drops — which is why that
section restates all three next to the measurements that separate them.
`pm.process_idle_timeout` governs only `ondemand`, and the `ondemand` figures
below use the 10s above with `pm.max_children` as stated there.

The test script does a fixed amount of arithmetic rather than sleeping to a
deadline, so its reported duration responds to contention instead of reporting
the same figure by construction. Calibration was linear on this machine and the
setting used below takes about 19.6 ms. Resident memory comes from `ps`, read
per worker process. macOS accounting differs from Linux, so treat the memory
figures as shapes rather than as numbers to carry to a server.

## What pm.max_children controls

`pm.max_children` is the maximum number of worker processes the pool will run,
and therefore the maximum number of requests it can execute at once. That is the
whole of it. It says nothing about memory, and FPM does not read the machine's
RAM, compare it against anything, or warn.

The multiplication that matters is `pm.max_children` times the memory a worker
holds at its peak, and no part of the configuration performs it. Twenty-four
workers fresh from a restart came to 52.1 MB together, 2.2 MB each. After light
traffic warmed them they came to 124.8 MB. After one request each that built a
large array they came to 2,514.6 MB, about 104.8 MB apiece — the same pool, the
same setting, forty-eight times the memory of the idle case, on a box where
nothing had changed. Those are medians of three repeats, each from a freshly
started pool; the loaded totals were 2,485.0, 2,514.6 and 2,520.3 MB.

## The three modes respond on different timescales

With `pm = static` the pool runs `pm.max_children` workers from startup and
keeps them there. Memory is predictable because it does not vary with load, and
idle workers cost their resident memory whether traffic arrives or not.

With `pm = dynamic` the pool starts `pm.start_servers` and adds workers to keep
spare capacity within bounds. What that costs is a response time. Configured
with `pm.max_children = 8`, `pm.start_servers = 2`, `pm.min_spare_servers = 1`
and `pm.max_spare_servers = 4` — two workers at rest — a burst of 48 concurrent
requests lasting about half a second was served by two to three workers, at a
client-observed median of 287.66 ms — against 73.17 ms for the same burst
against a static pool of eight.

At `pm.min_spare_servers = 1` the rate is one worker per second, and it is
strikingly regular. Under a saturating load the pool grew 2, 3, 4, 5, 6, 7, 8 on
consecutive seconds, reaching `pm.max_children` at about six seconds and
logging `server reached pm.max_children setting (8)`. The rate holds at a larger
ceiling: the same configuration with `pm.max_children = 16` climbed by exactly
one per second to sixteen, arriving between 14.06 and 15.06 seconds across
three runs.

Read those absolute times as good to about a second. The gaps between spawns are
stable; where the first one falls depends on the phase of FPM's maintenance tick
against the moment load arrives, which nothing in the test controls.

That rate belongs to `min_spare_servers` rather than to `dynamic`. FPM spawns
against the idle deficit, and a larger deficit is filled faster: at
`pm.min_spare_servers = 4` the same sixteen-worker pool went 4, 5, 7, 11, 15,
16 — steps of one, two, four, four, then a final one clipped by the ceiling —
reaching sixteen in about five seconds. Two things differ there, not one: it
also starts at four rather than two, so it made twelve spawns against fourteen.
The rate is the larger part of the gap but not the whole of it.

Where it stops is demand plus `pm.min_spare_servers`, or `pm.max_children`,
whichever is lower. Both halves are measured rather than inferred. Holding
`pm.start_servers` at 3 and varying only `pm.min_spare_servers`:

| Offered concurrency | min_spare_servers | Workers |
|---|---|---|
| 3 | 1 | 4 |
| 3 | 3 | 6 |
| 5 | 1 | 6 |
| 5 | 3 | 8 |

Every cell is the sum. `pm.max_spare_servers` does not enter into where a pool
stops *climbing*: at 2, 4 and 8, with concurrency held at 3 and `min_spare` at
1, the pool stopped at four every time.

Climbing is half of what a pool does. `pm.max_spare_servers` governs the other
half — where it settles back to once the load stops — and that is the half a
memory budget cares about. Driving the same pool up under saturating load — it
reached seven or eight workers depending on where the load happened to stop —
and then cutting the load entirely:

| pm.max_spare_servers | Workers once idle |
|---|---|
| 2 | 2 |
| 4 | 4 |
| 8 | 8 |

It sheds about one worker per second, the same rate at which it added them, and
then stops: twenty seconds of sampling with no traffic showed no further change.

Which matters here because of what an idle worker is holding. Repeating that
with requests that allocate, and reading the pool's resident memory instead of
its worker count:

| pm.max_spare_servers | Pool RSS at the spike | After 6 seconds idle |
|---|---|---|
| 2 | 1,228.0 MB across 6 workers | 408.8 MB across 2 |
| 8 | 1,228.3 MB across 6 workers | 1,228.3 MB across 6 |

Three repeats, agreeing within 0.7 MB. The spike is the same in both rows and so
is the per-worker high-water mark, about 205 MB; what differs is how many
workers are left alive to hold it. This is the decay from earlier seen from the
other end — a worker gives its memory back only by serving more requests, so a
pool that spiked and then went quiet keeps its peak until traffic returns, and
`pm.max_spare_servers` decides how many workers are there to keep it. Eight
hundred and nineteen megabytes, on this pool, from that one setting.

`pm.start_servers` has to move with them, because FPM validates all three
against each other and refuses to start otherwise:

```text
ALERT: [pool www] pm.start_servers(2) must not be less than pm.min_spare_servers(3) and not greater than pm.max_spare_servers(8)
```

Both halves of that line bind, and they pull in opposite directions. The
`min_spare` table holds `pm.start_servers` at 3, which its `min_spare = 3` row
requires; the `max_spare` runs hold it at 2, because 3 against
`pm.max_spare_servers = 2` is refused by the second half. So those two
experiments begin from three workers and from two. Where each pool starts
differs; where each one stops does not.

Seconds is the figure that matters, because a burst arrives in milliseconds.
Reaching a ceiling of eight took about six seconds at `min_spare = 1` and
reaching sixteen took about fifteen; even the aggressive `min_spare = 4` case
needed five. A spike shorter than a few seconds is served almost entirely by the
workers already running, which is what the half-second burst above measured.

With `pm = ondemand` no workers run until a request arrives. The pool I started
had zero processes at rest and one after a single request. How many it reaches
under load turns out to depend less on how many requests arrive than on how far
apart they arrive. Eight connections against `pm.max_children = 8`, varying only
the spacing between them:

| Arrival span for 8 connections | Workers |
|---|---|
| 0.03 ms | 2 |
| 1.23 ms | 6 |
| 5.38 ms | 8 |
| 21.15 ms | 8 |

Three repeats per row, each from a freshly started pool; all three agreed on
every row. The middle row is the one to distrust — it sits on the slope between
two and eight, so a busier machine can move it.

Eight simultaneous connections got two workers; the same eight spread across
five milliseconds got eight. The ceiling still binds — eight arrivals spread
that way against `pm.max_children = 4` got four, and logged the warning — but
below it the count tracks how much time the master had to react. That is the
same seconds-against-milliseconds mismatch `dynamic` shows, measured at a finer
grain. It suits a box hosting many mostly-idle pools, and it pays a fork on the
requests that find no worker free.

## Per-worker memory is a high-water mark that decays

A worker that handled one request peaking at 266.02 MB of PHP-managed memory sat
at 105.28 MB resident afterward. Left completely idle it held exactly 105.28 MB
across four seconds of polling, in each of three repeats — request shutdown did
not give it back, and time alone did not either.

What gives it back is more requests. In a separate run, with its own fresh
worker — which is why the opening figure sits 0.1 MB below the one above — I
sent trivial requests to the worker that had just handled the heavy one and read
its resident memory after each:

| Request after the heavy one | Worker RSS |
|---|---|
| none yet | 105.19 MB |
| 1 | 55.19 MB |
| 2 | 29.19 MB |
| 3 | 17.19 MB |
| 4 | 11.19 MB |
| 5 | 7.19 MB |
| 6 and beyond | 7.12 MB |

Three repeats, each starting from a freshly warmed worker. The widest spread on
any row was 0.02 MB, so a range column would say nothing: this sequence is very
nearly deterministic.

The excess halves on each subsequent request and five brought it back to
baseline. The allocator is releasing what it cached against some running measure
of recent demand rather than at the moment the request ended; the exact rule I
did not chase into the C source.

Two consequences follow. A worker that goes quiet after a heavy request holds
that memory for as long as it stays quiet, so a pool's resident total is set by
its recent peaks and not by what it is doing now. And every worker can be at its
peak at the same time, which is what the opening 2,515 MB was: not an unlikely
alignment, but the ordinary result of a burst of expensive requests. Peak times
`max_children` is the number to size against, and the peak is a property of what
the code allocates — [the per-element cost of a large
array](/blog/php-arrays-are-not-arrays/) is usually where a request's memory
actually goes.

## Queueing is invisible to a timer inside the request

Hold the offered load constant — 48 requests, 24 at a time, about 19.6 ms of
work each — and vary only `pm.max_children`. Medians in milliseconds, three
repeats each:

| pm.max_children | Client-observed latency | Duration the script reported |
|---|---|---|
| 2 | 263.93 / 270.67 / 276.75 | 20.90 / 21.19 / 24.66 |
| 4 | 146.63 / 137.46 / 139.50 | 26.24 / 23.19 / 22.76 |
| 8 | 63.82 / 73.57 / 73.17 | 25.34 / 25.57 / 25.91 |
| 16 | 62.35 / 62.11 / 61.68 | 37.45 / 37.82 / 39.69 |

The control, one request at a time against an eight-worker pool, gave 22.30 ms
at the client against 21.97 ms in the script.

At two workers the client waited about twelve times as long as the control while
the application reported 21 ms — its own control figure. An undersized pool does
not report slow code. It reports nothing at all, and the request duration your
monitoring records is measured from the moment a worker picked the request up,
which is after the wait.

Sixteen workers is the other failure. Client latency stopped improving past
eight, which is this machine's performance-core count, while the script's own
duration rose to 38 ms because sixteen CPU-bound workers were contending for
twelve cores. Oversizing converts queueing into contention and makes the
application's timings worse.

FPM's own diagnostics were less help than expected here. `listen queue` and
`max listen queue` on the status page read zero throughout, including while
twenty-two connections were demonstrably waiting. `max children reached` stayed
at zero under `pm = static`, which has no forking to refuse. It reported 1 under
both of the modes that do fork — `pm = dynamic` driven to its ceiling, and
`pm = ondemand` — and each wrote a warning to the error log, though not the same
warning. The `dynamic` path logs `server reached pm.max_children setting (8)`;
the `ondemand` path logs `server reached max_children setting (4)`, with no
`pm.` prefix. An alert that greps for the prefixed form alone will never fire on
an `ondemand` pool. That is the shape of FPM's self-reporting
throughout: it will tell you when it runs out of *workers*, and never when the
workers it has are consuming the machine. Whether the queue counters are a
platform limitation of this macOS build I did not establish. The measurement
that did work on every configuration was comparing a client-side timer against
the duration the script reported, which is a thing you can run against
production from one host.

## pm.max_requests recycles rather than repairs

With `pm.max_requests = 5` and one worker, the process ID changed after every
fifth request and resident memory reset to a fresh baseline. The first request
on a new worker cost 3.426 ms against about 3.3 ms steady, a difference this
method cannot resolve — OPcache holds compiled opcodes in shared memory, so a
replacement worker inherits them rather than recompiling.

What I could not do was make memory grow across requests in the first place.
With recycling off, a worker held steady at 5.70 MB over sixteen requests, and
every array I allocated was released at request shutdown. Growth that survives a
request comes from things that live outside its arena — persistent connections,
extension allocations, a genuine leak in C — none of which I measured here.
Recycling bounds those. It does not identify them, and a low `max_requests`
hides growth well enough that nobody investigates it.

## Sizing a pool from what you measured

Measure the peak resident memory of a worker under production traffic, not a
synthetic script, and take the peak rather than the average — the arguments for
measuring the real path in [benchmarking PHP without lying to
yourself](/blog/benchmarking-php-without-lying-to-yourself/) apply here, with
resident memory in place of elapsed time.

```bash
# Resident memory of every worker in a pool, largest first.
# The bracket stops awk from matching its own command line.
ps -o rss=,command= -ax \
  | awk '/php-fpm: pool ww[w]/ { printf "%8.1f MB\n", $1/1024 }' \
  | sort -rn
```

Against an eight-worker pool where three workers had recently handled a large
request, that printed:

```text
    65.5 MB
    65.4 MB
    65.4 MB
     5.7 MB
     4.9 MB
     4.8 MB
     4.8 MB
     4.8 MB
```

Eight lines for eight workers. The spread across them is the reason to size
against the top of that list rather than its average.

Decide how much of the box you are willing to hand the pool, leaving room for
everything else on it, then divide by that peak. That quotient is the ceiling
`pm.max_children` must respect. If the answer is smaller than your traffic
needs, the fix is less memory per request or more boxes, not a larger number.

Check that against cores. A pool larger than the machine can execute in parallel
turns waiting into contention, which showed up above as worse application
timings for no latency gain. For CPU-bound work the useful range topped out near
the performance-core count; for I/O-bound work, where workers spend their time
blocked, it sits higher, and the way to find it is to raise the setting until
client-observed latency stops improving.

Then instrument the gap. One timer outside the request and one inside it, with
the difference recorded, is the signal that says a pool is too small — and it is
the one signal that an application-side profiler, sitting entirely inside the
worker, cannot produce.
