php-fpm max_children warning: find the cause in the logs before adding workers

Affiliate disclosure v3.1
Affiliate disclosure v3.2 (mobile)
Test environment RHEL familyAlmaLinux 9.8root accountChecked 2026-10-12

The php-fpm max_children warning is a sign that every PHP worker process is busy handling requests, so new requests have gone into the queue. On a WordPress server running Nginx, Apache and php-fpm where this warning had been piling up for weeks, I used the logs to single out the requests that filled the workers, and solved it with an nginx rate limit instead of raising the setting.

In the weekly check the warning was logged 27–187 times a day, yet visitors got zero 502 or 504 errors over the same four days. What filled the workers was not visitors but a bot hammering admin URLs and a scanner requesting paths that do not exist.

1️⃣ php-fpm max_children & seems busy

When the warning piles up to 66 a day, it is easy to read that as 66 times the workers ran out. The php-fpm log mixes two different warnings, and most are early warnings logged before the limit is reached.

[09-Oct-2026 00:16:18] WARNING: [pool www] seems busy (you may need to increase pm.start_servers, or pm.min/max_spare_servers), spawning 8 children, there are 0 idle, and 15 total children
[09-Oct-2026 13:02:05] WARNING: [pool www] server reached pm.max_children setting (26), consider raising it

[seems busy] means there are 0 idle workers and a new process is being spawned; with process management set to dynamic, even a brief rise in requests logs it. [server reached pm.max_children] is the moment the limit of 26 was hit and no more could be spawned, and from then on new requests wait.

It is the difference between calling in one more waiter and leaving guests standing outside because every seat is taken.

Counting one day by type gave 58 seems busy and 6 max_children. The check routine added both and reported 66, but the limit was actually reached six times.

grep '09-Oct-2026' /var/log/php-fpm/error.log | grep 'pool www' | grep -c 'seems busy'
58
grep '09-Oct-2026' /var/log/php-fpm/error.log | grep 'max_children' | cut -c2-21
09-Oct-2026 13:02:05
09-Oct-2026 13:52:10
09-Oct-2026 18:09:20
09-Oct-2026 18:42:17
09-Oct-2026 18:46:07
09-Oct-2026 19:12:44

The six times printed by the second command become the reference for tracing the cause; the next step uses only these times.

2️⃣ Why raising max_children is not the answer

Raising the limit of 26 may reduce the number of warnings, but it does not change which requests filled the workers.

The usual calculation divides available memory by one worker's average memory. On the measured server, 26 workers used 131MB on average and 214MB at most, 3.4GB in total, and 4.1GB of the 15.7GB of memory was available, so on paper 30 or more additional workers could be spawned.

ps -o rss= -u www -C php-fpm | awk '{s+=$1; n++; if($1>m) m=$1} END{print n, s/n/1024, m/1024, s/1024}'
26 130.579 214.27 3395.05
free -m
               total        used        free      shared  buff/cache   available
Mem:           15783       11658         397         340        4383        4124
Swap:           8191        1615        6576
nproc
4

The four values in the first output are worker count, average memory (MB), maximum memory (MB) and total (MB); the available column of the free output is the available memory.

With 4 CPU cores, more workers do not increase the requests handled at once; they only lengthen the time each request takes. While visitors get zero 502 or 504 errors, raising the limit gains nothing and simply gives more processes to the bots and scanners that were occupying the workers.

Before raising the limit, check whether the requests occupying workers at the warning times were visitors or not; the next section lays out how to check that in access.log.

3️⃣ Tracing the cause in access.log: time, IP, path, status code

Pick one warning time and the answer is in the access.log for that 1–2 minutes. The order is: extract the lines by time, tally by IP, then check the paths and status codes of the top IPs.

grep '09/Oct/2026:18:09' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head
    115 140.245.x.x
     61 187.15.x.x
     17 3.36.x.x
      8 91.239.x.x
      4 79.127.x.x
      3 109.248.x.x
      3 109.248.x.x
      3 109.248.x.x
      2 79.127.x.x
      2 40.77.x.x
grep '09/Oct/2026:18:09' /var/log/nginx/access.log | awk '{print $7, $9}' | sort | uniq -c | sort -rn | head
     55 / 200
     13 /wp-login.php 404
     13 /wp-admin/plugins.php 302
     13 /wp-admin/index.php 302
     13 /wp-admin/edit.php 302
     12 /wp-admin/profile.php 302
      4 / 499
      3 /xmlrpc.php 302
      2 /xmlrpc.php 499
      2 /wp-content/themes/kinsta/images/duos/small/wheelchair.svg 404

At 18:09 on October 9, 286 requests were logged in one minute; one IP accounted for 115 of them, the paths were admin URLs such as /wp-admin/profile.php, and every status code was 302.

The status code tells you whether PHP was reached. A 200 for cached HTML served directly by nginx, and 444 or 400, never touch PHP, while 302, 404 and an uncached 200 each use a worker. When a logged-out client requests an admin URL, WordPress responds with a 302 to the login page, and that one 302 is one PHP execution.

If one server hosts two or more sites, the global access.log records lines from both. At first I thought this bot was attacking the first site, but comparing with each site's own log showed that 22,390 of the 22,391 requests were for the second site.

4️⃣ Three kinds of requests that filled the workers: bot wp-admin hits, scanner 404s, cache preload

Applying the same order to every time a warning was logged showed three kinds of requests filling the workers.

The first was a bot repeatedly requesting admin URLs. One IP sent 22,391 requests in three hours; 9,944 of them were to four paths including /wp-admin/profile.php, and since each logged-out 302 is one PHP execution, it used a worker 9,944 times.

The second was a vulnerability scanner requesting paths that do not exist. Even with Googlebot in the User-Agent it is not Googlebot: the IP that sent 14,672 requests a day was a Google Cloud virtual server whose reverse DNS ends in googleusercontent.com, while Googlebot ends in googlebot.com. The same IP alternated between bingbot, Applebot, GPTBot and ClaudeBot.

Requests for files like .env are cut off by nginx with a 444 in 0.04 seconds, but ordinary paths not covered by the block rules, such as /userfiles or /secrets.yml, make WordPress generate a 404 page. That 404 page costs 266KB and 1.4–2.9 seconds, the same as generating a normal post, and one IP requested it 4,069 times a day.

The third was the server itself. Saving a post or translation clears the cache and WP Rocket's preload requests every URL again, which came to 8,489 requests a day and up to 60 a minute. Rank Math's index status checks also used a worker 754 times a day via Action Scheduler. Both concentrated on working hours and caused no visitor errors, so I left them out of the fix.

In the simulator below you can see at which layer each request ends and whether it uses a PHP worker.

🧭 Request path simulatorwp-admin Admin PageExplanatory simulator
🗂️ Requests that filled the workers
Bot wp-admin requests
When a logged-out client requests an admin URL, WordPress responds with a 302 to the login page. That one 302 is one PHP execution.
Scanner: .env-type files
Requests for files like .env are cut off by nginx's block rule with a 444. 0.04 seconds, and PHP is never touched.
Scanner: ordinary fake paths
Ordinary paths not covered by the block rules, such as /userfiles or /secrets.yml, make WordPress generate a 404 page. 266KB and 1.4–2.9 seconds, the same cost as generating a normal post.
Cache preload
Saving a post or translation clears the cache, and WP Rocket's preload requests every URL again. Up to 60 a minute, each request using one worker.
Visit to a cached post
If a cache file exists, nginx answers directly and PHP is never touched.
Visit to an uncached post
If there is no cache file, PHP generates the page and saves that HTML as a cache file.
Full flow
A request goes down from the top and stops at the layer where the response ends. Only requests that go all the way down to WordPress use a PHP worker.
RequestStops atResponseWorkers
Bot wp-adminWordPress302 (to the login page)Uses 1
Scanner .env-typenginx block rule444 (0.04 s)Not used
Scanner ordinary fake pathWordPress404 page (266KB · 1.4–2.9 s)Uses 1
Cache preloadWordPress200 · saved as cache fileUses 1
Visit to a cached postCache file200 (nginx answers directly)Not used
Visit to an uncached postWordPress200 · saved as cache fileUses 1
An explanatory simulator that runs only in your browser. Responses and times are taken from logs and measurements of a server running Nginx + Apache in October 2026.

5️⃣ nginx rate limiting: limiting logged-out requests and restoring the real IP

I chose rate limiting over blocking. Block a path and the scanner switches paths; block an IP and a different IP shows up the next day. Only logged-out requests that reach PHP are limited, to 30 per minute and 60 at once per IP, and the excess gets a 429.

For a server behind a CDN, this setting alone is not enough. The IP nginx sees is the CDN edge address, so every visitor coming through that edge is lumped into one IP; you first need to register the edge IP list with set_real_ip_from to restore the real IP from X-Forwarded-For. Requests with a login cookie are exempt, but nothing is exempted by User-Agent.

# http context
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from 203.0.113.0/24;   # CDN edge list
map $http_cookie $php_anon_key {
    default                 $binary_remote_addr;
    ~*wordpress_logged_in   '';
}
limit_req_zone $php_anon_key zone=php_anon:10m rate=30r/m;

# location that passes to PHP
location @apache {
    limit_req zone=php_anon burst=60 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:8080;
}

Cached pages are answered inside nginx by try_files and never reach this location, and admin-ajax.php is a separate location, so it is not included in the limit.

Test with concurrent requests. Sequential requests never exceed 30 a minute because one 404 takes 1.5 seconds, so they never hit the limit; sending 100 requests 20 at a time gives 67 404s and 33 429s, confirming the limit works.

seq 1 100 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' https://example.com/zz-{} | sort | uniq -c

If the output counts 429s, the limit is working. If you exempted the server's own IP, lift the exemption briefly while testing or run it from another network.

6️⃣ Before and after

The limit on logged-out admin URLs went live on the evening of October 9, and the limit on requests reaching PHP on the evening of the 11th. Admin URL 302s fell from 9,996 on the 9th to 11 on the 10th and 1 on the 11th, and the IP that sent 22,391 requests in three hours has sent none since the next day.

For the scanner the limit has only just been applied, so there is no full day of numbers yet; the judgment will come from how far the number of 404 pages generated by WordPress (2,563 on the 9th, 2,612 on the 10th, 1,630 on the 11th) drops in next week's check. Visitor 502 and 504 errors were 0 both before and after.

These numbers were tallied directly from the logs of a WordPress server running Nginx, Apache and php-fpm, and the judgment was based not on the number of warnings but on visitor errors and requests reaching PHP.

📊Before and after the rate limitPer day · log tally
BeforeAfter
Admin URL 302 responses
302s WordPress sent to logged-out wp-admin requests. The limit on logged-out admin URLs went live on the evening of October 9.
02,4994,9987,4979,996
10/99,996Before
10/1011After
10/111After
Requests from the bot IP
Daily requests from the one IP that sent 22,391 in three hours.
05,59811,19616,79322,391
10/922,391Before
10/100No requests
10/110No requests
404 pages generated by WordPress
Scanner requests to ordinary fake paths. The limit on requests reaching PHP went live on the evening of October 11, so it is judged by next week's check.
06531,3061,9592,612
10/92,563Before
10/102,612Before
10/111,630Before · applied evening of the 11th
Numbers are daily tallies of the nginx access.log from October 9 to 11, 2026.

🔢 FAQ & Recommended Content

dynamic creates and stops workers according to request volume and logs the seems busy warning when idle workers reach 0. static always keeps max_children workers, so there is no spawn delay or warning, but the memory is always occupied. This server kept dynamic with 26; if you have spare memory and request volume is steady all day, static is the right fit.

Turn it on when specific requests seem to run for a long time at the warning times. Set the [slowlog] path and [request_slowlog_timeout] in the pool config, and the execution point of any request exceeding that time is logged. In this case the cause was the number of requests, so tallying access.log was enough and slowlog was not used.

The limit only applies to logged-out requests that bypass the cache and reach PHP, and only the excess gets a 429 when one IP goes over 30 per minute or 60 at once. In the tally for October 11, the only IP over that line was a scanner, and the excess state clears within a minute, so the next request gets a normal response.

I did not. Preloading concentrated on the hours when posts and translations were saved and never caused visitor errors. The yardstick is not the number of warnings but whether visitors got 502 or 504 errors; if that number is 0, the preload stays as it is.

ℹ️ Affiliate Disclosure
This site's content contains affiliate links. When a visitor buys a product or service through one of them, the site receives a commission from the seller. The amount the buyer pays(it goes down during event discounts ↓)does not go up. Posted prices, discounts, and stock reflect the time of writing and may differ, so confirm with the seller before buying. Products are chosen and reviewed by our own standards, and commissions do not affect the order or content of reviews.

Leave a Reply

Your email address will not be published. Required fields are marked *

Prove your humanity: 5   +   6   =