PHP-FPM runs fine for hours or days, then per-worker RSS slowly climbs, the box runs low on RAM, and the OOM killer shoots workers or the master. Restarting PHP-FPM fixes it immediately, and the cycle repeats. The root cause is usually a memory leak in application code, an extension, or allocator fragmentation. The reason it becomes an incident instead of a slow nuisance is almost always the same missing safety net: pm.max_requests = 0.
pm.max_requests is the single most common PHP-FPM misconfiguration. The default in the PHP source and in most distro packages is 0, which means unlimited. With no limit, every worker lives forever and any leak accumulates without bound. Setting it to a finite value forces periodic worker recycling: after handling N requests, a worker finishes its current request, delivers the response, and exits with code 0. The master forks a replacement that starts with a fresh memory baseline.
This guide covers why recycling works, how to confirm it is happening, how to pick a value, and how to avoid the two common mistakes: leaving it at 0, or setting it so low that fork overhead dominates. For finding the leak itself, see PHP-FPM memory leak: per-worker RSS climbing until the box runs out.
What this means
When pm.max_requests is set to a positive integer N, each worker counts the requests it has served. The moment it crosses N, it does not die mid-request. It finishes the current request, sends the response, and only then exits. The master process forks a replacement via its normal SIGCHLD lifecycle. From the user’s perspective there is no interruption.
PHP-FPM is a process-based, one-request-per-worker model. A worker is a full OS process carrying the PHP runtime, loaded extensions, shared opcache pages, and whatever in-process state the application has accumulated. PHP’s memory_limit constrains a single request’s heap; it does not bound what a worker accumulates across thousands of requests. Extension allocations, static caches, circular references the garbage collector misses, and allocator fragmentation all grow outside that limit. Without recycling, the only thing that resets a worker’s memory is a crash or a restart.
The key framing: pm.max_requests is a safety net, not a cure. It converts an unbounded, monotonically growing leak into a sawtooth pattern bounded by the recycle threshold. If you set it to 500 and RSS climbs by a fixed amount per request, each worker peaks at a predictable level above baseline before resetting. The leak is still there. You still want to find it. But the OOM spiral is gone.
A common fear is that recycling clears OPcache and forces recompilation. It does not. OPcache lives in a shared memory segment that workers attach to, not private memory inside each worker. When a worker exits and is replaced, the opcache segment is untouched. Values in the 500 to 5000 range are safe even on large frameworks with heavy opcache dependence.
flowchart TD
A[Worker spawned
fresh RSS] --> B[Handle request]
B --> C[RSS grows slightly
leak accumulates]
C --> D{max_requests set?}
D -->|No, default 0| E[Loop without bound
RSS climbs forever]
E --> F[OOM killer strikes
worker or master]
D -->|Yes, e.g. 500| G{Served its limit?}
G -->|No| B
G -->|Yes| H[Finish request
deliver response, exit code 0]
H --> I[Master forks replacement]
I --> ACommon causes
The cause is not the leak. The cause is the missing or misconfigured safety net that lets the leak become an incident.
| Cause | What it looks like | First thing to check |
|---|---|---|
pm.max_requests = 0 (default) | Per-worker RSS climbs monotonically for hours or days; OOM events recur after every restart | grep -rn 'pm\.max_requests' in pool config |
| Value set but far too high (e.g. 100000) | Same slow bloat; recycling happens so rarely it never resets memory in time | Compare the value against your per-worker request rate |
| Config change not applied | Workers in full status show request counts well above the configured limit | Confirm the pool was reloaded; check effective value with php-fpm -tt |
| Value set far too low (e.g. 50) | Elevated system CPU, high fork rate, brief latency spikes on cold workers | Check worker exit volume and fork rate in the error log |
Quick checks
These are read-only and safe to run on a production host.
# Check the effective pm.max_requests (prints the runtime config, not just the file)
php-fpm -tt 2>&1 | grep -i "max_requests"
grep -Rni "pm.max_requests" /etc/php /etc/php-fpm* 2>/dev/null
# Per-worker request count and age from the full status page.
# The JSON field is "requests" (number of requests served by this worker).
curl -s "http://127.0.0.1/fpm-status?json&full" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for p in d['processes']:
print(f\"pid={p['pid']} state={p['state']} served={p['requests']} age={p['start since']}s\")"
# Per-worker RSS (excludes the master). RSS double-counts shared opcache pages.
ps -eo pid,rss,cmd | grep '[p]hp-fpm' | grep -v master | sort -nk2 | tail
# Total FPM memory footprint vs available RAM
ps -eo rss,cmd | grep '[p]hp-fpm' | grep -v master | awk '{s+=$1} END {printf "FPM total: %.0f MB\n", s/1024}'
free -m
# Worker exit events and exit codes from the FPM error log.
# Log path varies by distro; common locations: /var/log/php-fpm/error.log, /var/log/php8.x-fpm.log
grep "exited" /var/log/php-fpm/error.log | tail -20
# OOM kills targeting php-fpm (may require root)
dmesg -T | grep -i "out of memory\|oom" | grep -i php
The most diagnostic single check is the full status output. If pm.max_requests is set to 500 and you see workers with request counts in the tens of thousands, recycling is not happening and the config was not applied.
How to diagnose it
Confirm the leak is real. Poll per-worker RSS a few times across a window of normal traffic. A leak shows up as RSS rising for individual workers between requests, not just a one-time warmup jump. Newly forked workers start small and grow as they warm up; that initial climb is normal. Sustained upward drift across hours is not.
Confirm the safety net is configured at runtime. Run
php-fpm -ttto print the effective configuration, not just grep the file. A file edit that was never followed by a reload, or that was overridden by a later pool include, will leave the runtime still at 0.Confirm recycling is actually occurring. From the full status page, the request count for each worker should cluster near or below the configured limit. If values exceed the limit, the change was not applied or the worker predates the reload. A reload (SIGUSR2) re-execs the master and respawns workers, so per-worker counters reset at that point.
Confirm the exits are the right kind. In the error log, recycling exits show up as children exiting with code 0. Anything on signal 11 (SIGSEGV), signal 7 (SIGBUS), or signal 9 (SIGKILL from the OOM killer) is a crash or a kill, not recycling. Periodic, predictable code-0 exits at an interval consistent with your request rate and
max_requestsvalue are the healthy signature.Size the safety net against your traffic. Estimate how long a worker lives at your request rate. At 10 requests per second per worker and
pm.max_requests = 500, each worker recycles roughly every 50 seconds. If your leak grows fast enough to OOM a worker inside that window, the net is too coarse and you need a lower value, or to fix the leak urgently.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
| Per-worker RSS | Detects leak growth between recycles | Monotonic rise with no plateau across hours |
| Request count per worker (full status) | Verifies recycling is actually happening | Values exceeding the configured max_requests |
| Worker exit rate and exit code | Distinguishes healthy recycling from crashes | Non-zero exits, SIGSEGV/SIGBUS, or SIGKILL |
| Total FPM memory vs available RAM | The cliff the safety net exists to avoid | FPM footprint approaching the system or cgroup limit |
OOM kill events in dmesg | The failure mode we are preventing | Any Out of memory entry targeting php-fpm |
| Fork rate and system CPU | The cost of recycling if the value is too low | Sustained high fork rate with low request volume |
Fixes
Set pm.max_requests explicitly on every pool
The immediate mitigation for the memory-leak spiral is to set a finite value and reload. A value in the 500 to 1000 range is the widely used default and is near-zero cost: a fork every few hundred requests adds negligible overhead relative to the request work itself.
In the pool configuration (commonly /etc/php/<version>/fpm/pool.d/www.conf or /etc/php-fpm.d/www.conf):
pm.max_requests = 500
Apply with a graceful reload, which drains workers, re-execs the master, and respawns workers with fresh memory:
# Graceful reload. There is a brief window with reduced capacity while workers drain.
# Service name varies by distro (php-fpm, php8.2-fpm, etc.)
systemctl reload php-fpm
# Or, if your unit does not alias reload to SIGUSR2:
kill -USR2 $(cat /run/php-fpm.pid)
The reload is not free. During SIGUSR2 the old workers drain first and the master re-execs before new workers are spawned, so there is a brief window with reduced or zero capacity. Avoid running it mid-incident when you can. If the box is already OOMing, a reload is still the right call because it resets memory, but expect a short blip.
Verify the change took effect
After the reload, check three things: the effective value via php-fpm -tt, the per-worker request count in full status (should now be bounded by the new limit), and the error log (should start showing periodic code-0 exits).
Choose a value that matches the leak rate
There is no universal right number. Too low and you pay fork overhead and brief warmup latency on every cycle. Too high and a fast leak accumulates enough to hurt before the recycle fires. For most applications, 500 to 1000 is the sweet spot. For lean API workers with small per-request memory and a known-clean codebase, a higher value keeps fork overhead down. For workers handling heavy, leak-prone paths such as image processing or large XML parsing, a lower value contains the damage.
If you are not sure, start at 500, watch the RSS sawtooth, and adjust so peak RSS per worker stays well under the memory budget implied by max_children.
Treat the leak as the real problem
pm.max_requests stops the OOM spiral. It does not stop the leak. If RSS climbs back to the same peak within a few requests of each recycle, the leak is fast and you should hunt it. Compare RSS growth across workers handling different endpoints, use the last request memory field in full status to spot memory-heavy requests, and instrument the application with memory_get_usage() and memory_get_peak_usage() around suspect paths. The deeper investigation is covered in the memory-leak guide linked at the end of this article.
Prevention
- Set pm.max_requests explicitly, everywhere. Never rely on the default. Treat
0as a misconfiguration, not a valid choice, for any production pool. The upstream pool template comments the directive out with a sample value of 500. - Confirm recycling with per-worker request counts. A configured value that was never applied is functionally the same as 0. Make the request count a signal you check regularly, not a one-time verification.
- Pair it with request_terminate_timeout. Recycling handles cumulative memory;
request_terminate_timeouthandles stuck requests that hold a worker forever. You need both. See PHP-FPM request_terminate_timeout: stopping stuck requests from eroding the pool. - Watch the RSS sawtooth, not just the peak. A healthy pool with recycling shows RSS rising and resetting in a bounded pattern. A flat line at a high value means recycling is not firing; a sawtooth with a rising baseline means the leak is outpacing the recycle rate.
- Budget memory against max_children. The safety net bounds per-worker memory, but total memory is still
max_childrentimes peak per-worker RSS. Raisingmax_childrenwithout checking memory is how teams trade a leak problem for an OOM problem.
How Netdata helps
- Per-second collection of active, idle, and total process counts shows the recycle cadence and any capacity dip during reloads at the resolution they actually happen.
- Per-worker RSS tracking surfaces the leak signature directly: a monotonic climb with
pm.max_requests = 0, and a bounded sawtooth once recycling is configured. Trend and anomaly detection catches the slow drift that minute-level polling misses. - Correlating RSS growth with worker exit events distinguishes healthy code-0 recycling from crash exits (SIGSEGV, SIGBUS) and OOM kills (SIGKILL) without cross-referencing logs by hand.
- Memory metrics for both the host and the cgroup show how the FPM footprint relates to the actual limit that will trigger the OOM killer. In containers that limit is the cgroup, not host RAM.
- Alerting on
max children reached, listen queue depth, and request duration gives the saturation context that tells you whether a recycling-related capacity dip is user-visible.
Related guides
- PHP-FPM memory leak: per-worker RSS climbing until the box runs out
- PHP-FPM workers OOM-killed: “child N exited on signal 9 (SIGKILL)” and the memory cliff
- PHP-FPM request_terminate_timeout: stopping stuck requests from eroding the pool
- PHP-FPM active processes near max_children: reading pool utilization
- PHP-FPM idle processes at zero: no burst headroom left
- PHP-FPM listen queue growing: the earliest signal of saturation
- PHP-FPM monitoring checklist: the signals every production pool needs
- How PHP-FPM actually works in production: a mental model for operators






