PHP-FPM: Dynamic vs OnDemand vs Static
PHP-FPM ships with three process manager modes — static, dynamic, and ondemand — and the default pm = dynamic setting in most distro configs is rarely the right call once a site has to survive real traffic on a budget VPS. I've rebuilt this exact config more times than I can count on client Laravel and WordPress boxes, usually right after a "why did the server run out of memory at 2am" ticket, and the fix is almost always the same: the pm mode was never actually chosen, it was just inherited from whatever the install script wrote.
This post is the comparison I wish existed the first time I had to make this call: what each mode actually does under load, where it breaks, and which one I'd pick for a given box size — not a restatement of the PHP manual.
What pm actually controls
PHP-FPM's pm directive decides how child worker processes get created and destroyed. Every PHP request your web server hands off to FPM gets picked up by one of these workers, and each worker holds its own chunk of memory — this is the number that actually determines whether your server survives a traffic spike or falls over with an OOM kill.
The three modes answer the same question differently: how many workers should exist right now, and how aggressively should FPM spin new ones up or tear idle ones down?
static — a fixed worker count, no surprises
; /etc/php/8.3/fpm/pool.d/www.conf
pm = static
pm.max_children = 12
With static, FPM starts exactly pm.max_children workers at boot and keeps them running forever, busy or idle. There's no spin-up delay for the 13th request because there is no 13th worker to spin up — it queues until one frees up.
This is the mode I reach for on a dedicated box with memory to spare and a workload that's consistently busy (a high-traffic API, not a marketing site that gets three requests an hour). The tradeoff is upfront: you're paying for max_children × average_worker_memory in RAM at all times, whether anyone's visiting or not.
dynamic — a sliding range with five extra knobs
pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 6
dynamic tries to be adaptive: it starts with start_servers, grows toward max_children under load, and shrinks back down when traffic eases — but never below min_spare_servers. On paper this sounds like the best of both worlds. In practice it's the mode most people copy-paste without tuning, and it's also the one most likely to be misconfigured, because now you've got five interdependent numbers instead of one.
Here's where most tutorials stop short: they tell you to set pm.max_children based on total RAM / average process size and move on. That math is right, but it ignores that min_spare_servers workers sit idle 24/7 consuming memory even on a site with almost no traffic — which is exactly the scenario ondemand was built for.
ondemand — spin workers up from zero, kill them when idle
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
ondemand keeps zero workers running when there's no traffic and spawns them the moment a request arrives, killing each one off after process_idle_timeout seconds of inactivity. The cost is a few milliseconds of process-spawn latency on a cold request — usually irrelevant unless you're running something latency-sensitive behind a load balancer with aggressive health checks.
I'd skip dynamic entirely on anything smaller than a 4GB VPS and go straight to ondemand. On a handful of client sites running on 1-2GB droplets, switching from dynamic to ondemand dropped idle memory usage by 150-300MB — the exact margin that was causing MySQL and PHP-FPM to fight over RAM and trigger the OOM killer during traffic bursts.
Picking a mode: a direct comparison
static |
dynamic |
ondemand |
|
|---|---|---|---|
| Idle memory use | High (fixed) | Medium (min_spare floor) | Near zero |
| Cold-start latency | None | None for spares, some beyond | Small, on every scale-up |
| Config complexity | 1 directive | 5 directives | 2 directives |
| Best for | Busy, dedicated boxes | Mid-traffic, well-tuned boxes | Small VPS, bursty/low traffic |
| Worst case | Wastes RAM when idle | Misconfigured spares waste RAM too | Slightly slower first request after idle |
Working out pm.max_children for real
Whichever mode you land on, pm.max_children is the number that actually matters, and it's a function of your server's RAM and your app's real memory footprint — not a round number picked because it looked reasonable.
# Check average FPM worker memory in use right now
ps -ylC php-fpm8.3 --sort:rss | awk '{print $8}' | tail -n +2
Take that average (call it A in MB), subtract what the rest of the stack needs (MySQL, Redis, Nginx, the OS itself — budget at least 400-600MB for that on a small box), and divide the remainder by A. On a 2GB droplet with a 70MB average Laravel worker and 600MB reserved for everything else, that's roughly (2048 - 600) / 70 ≈ 20 as an upper ceiling — I'd still set pm.max_children a bit under that, not right at the edge, because traffic spikes and memory spikes rarely stay this tidy in practice.
Frequently Asked Questions
My site crashes under load even with ondemand set — why?
Usually pm.max_children is set too high for the box's actual RAM, so FPM happily spawns workers right up until the kernel's OOM killer steps in. Rerun the memory math above using real numbers from ps, not a guess.
Does switching pm mode require a full server reboot?
No — just sudo systemctl reload php8.3-fpm (or restart if reload doesn't pick up pool changes on your distro's FPM build). No downtime beyond the brief worker respawn.
Why does my app feel slightly slower right after a quiet period on ondemand?
That's the cold-start cost — FPM has to fork a fresh worker for the first request after process_idle_timeout kills the idle ones. If that latency genuinely matters for your use case, raise process_idle_timeout so workers survive longer between bursts, or move to dynamic with a small min_spare_servers floor.
Can I run multiple pools with different pm modes on the same server?
Yes, and it's underused — a pool serving a webhook endpoint that needs to always be warm can run static, while a pool serving the admin dashboard on the same box runs ondemand. Each pool gets its own .conf file in pool.d/.
Where I'd land on this
For anything under 4GB of RAM: use ondemand, tune pm.max_children from real worker memory numbers, and move on — it's the mode with the fewest ways to accidentally waste memory you don't have. For a dedicated, consistently busy box, static at a number you've actually calculated beats dynamic's complexity for no real latency win. I'd only reach for dynamic on a mid-size box where traffic genuinely varies through the day and you're willing to actually tune all five of its directives — not just copy the defaults and hope.
Related posts
Fix "Too Many Open Files" on Linux
Server crashing with EMFILE or "too many open files"? Here's what causes it and how to fix ulimit, limits.conf, and systemd LimitNOFILE for good.
Nginx Reverse Proxy Setup for Node.js Apps
Configure Nginx as a reverse proxy for Node.js: headers, WebSockets, HTTPS, and the mistakes that cause 502s in production.
Fixing OpenAI API Timeouts in Laravel Jobs
OpenAI API calls timing out inside your Laravel queue? Here's why jobs fail mid-request and how to set timeouts that actually agree with each other.
AI Prompt to Write Unit Tests for Legacy PHP
Learn how to write an AI prompt that generates real unit tests for legacy PHP code, including the hidden edge cases a generic prompt misses.
0 Comments
No comments yet — be the first to share your thoughts.