PHP workers are the least visible and most misunderstood limit in WordPress hosting. A plan can advertise generous storage, unlimited visits, and a fast CPU, then fall over at 300 concurrent shoppers because the PHP pool is full. The site is not out of memory and the CPU is not pegged. It is out of concurrency.
This guide explains what a worker does, how to estimate the pool you need, how to observe saturation, and where managed hosts hide the limit. It is intentionally practical: the goal is a number you can put in a plan comparison, not a computer-science lecture.
- A worker is occupied for the whole request. A 400 ms page holds a worker for 400 ms.
- Queueing is non-linear. Above ~70% utilization, small traffic increases create large latency increases.
- CPU is a lagging signal. p95 rises while CPU is still at 60% because requests are waiting, not computing.
- Background jobs compete with checkout. Cron, imports, and subscription renewals consume the same pool.
- More workers is not always better. Memory and database connections become the next bottleneck.
What a PHP worker actually does
On a conventional LEMP stack, Nginx receives the request and passes PHP files to PHP-FPM. PHP-FPM maintains a pool of child processes. Each child bootstraps WordPress, loads active plugins and the theme, runs the request, and returns the response. The child is busy for the entire time, including any database queries and outbound API calls.
A full-page cache can serve anonymous HTML without invoking PHP at all. That is why a cached homepage can handle thousands of requests per second on a small server. But a cart, checkout, login, admin page, or uncached template still needs a worker. On a store, the requests that create revenue are the ones that consume the limited pool.
The queue between Nginx and PHP-FPM is where a site “hangs” without high CPU. Monitor it directly.
The concurrency math
Little's law gives a useful approximation: the average number of requests in the system equals the arrival rate multiplied by the average time each request spends in the system. In worker terms: concurrent requests = requests per second × average duration in seconds. Divide by target utilization to leave headroom.
# Example A: a content site with a warm page cache
# 40 dynamic requests/sec (uncached/admin), 0.25 s each, 70% target
workers = (40 * 0.25) / 0.70
# = 14.3 -> 15 workers
# Example B: a WooCommerce store at campaign peak
# 120 dynamic requests/sec, 0.40 s each, 70% target
workers = (120 * 0.40) / 0.70
# = 68.6 -> 69 workers
# Add a buffer for cron, imports, and forecast error
workers_with_buffer = 69 * 1.5
# = 104 workers
The formula is only as good as the inputs. Measure the request rate and duration from your own logs, not from a generic benchmark. Separate cacheable from dynamic requests. If 90% of your traffic is cached, the worker requirement is driven by the remaining 10% — plus the logged-in and checkout traffic that never hits the page cache.
Why p95 explodes before the pool is full
Queueing is non-linear. At 50% utilization, a request rarely waits. At 70%, waits are noticeable. At 85%, the tail grows quickly. At 95%, a small burst produces a long queue. This is why a host can look fine at 400 concurrent users and fail at 500: the system crossed the knee, and the next 100 users are waiting behind the first 400.
Averages hide this. If 95% of requests are served in 200 ms and 5% wait 2 seconds, the average may still look acceptable while real customers experience the tail. Report p95 and p99, and watch the queue depth, not just the mean.
The queueing curve
Latency grows slowly, then sharply, as utilization approaches 100%.
How to see saturation
On a server you control, enable the PHP-FPM status page and scrape it. The key fields are active processes, idle processes, listen queue, max children reached, and slow requests. If active processes equal the pool size and the listen queue is non-zero, you are saturated.
; php-fpm.d/www.conf — status and slow log (example)
pm.status_path = /status
ping.path = /ping
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 2s
; Pool sizing (example only — tune to RAM and workload)
pm = dynamic
pm.max_children = 40
pm.start_servers = 8
pm.min_spare_servers = 6
pm.max_spare_servers = 16
pm.max_requests = 500
On a managed host, the panel may expose worker utilization or only a plan limit. Ask support what happens at the limit and how to monitor it. If the answer is “the site may return 502,” treat the worker count as a hard capacity constraint and load-test against it.
At the application layer, correlate p95 with query count and slow queries. A worker held for 800 ms by an unindexed query is a database problem disguised as a worker problem. Adding workers will push the queue into MySQL and make the incident worse.
How hosts limit workers
| Host | Model | What to ask |
|---|---|---|
| Kinsta | Plan-based PHP worker allocation | Workers per plan, behavior at the limit, upgrade path |
| WP Engine | Plan-based workers and visit tiers | Whether visits or workers bind first; overage behavior |
| Cloudways | Configurable PHP-FPM on your server | RAM per worker, safe max_children, database connections |
| Rocket.net | Managed pool with plan limits | Worker ceiling and autoscaling behavior |
| Pressable | Managed platform limits | Plan concurrency and burst policy |
Plan structures change. Confirm the current allocation and, more importantly, what happens when you exceed it. A queue with a slow response is different from an immediate 502.
WooCommerce: where workers go
A WooCommerce checkout is not one request. It is a sequence: cart update, shipping calculation, coupon validation, payment intent, order creation, and confirmation. Each step can consume a worker, and some steps call external APIs. A checkout that takes 1.2 seconds of worker time per order limits you to roughly 50 orders per minute with 1 worker, or 500 with 10 — before you account for catalog and admin traffic.
Add the background load: subscription renewals, failed-payment retries, abandoned-cart jobs, and order emails. These run through WP-Cron or Action Scheduler and use the same PHP pool. A renewal batch at 9 a.m. can starve checkout at 9:05 a.m. Move heavy jobs off-peak or to a separate runner.
Do not solve a database problem with workers. If the database is the constraint, more workers create more concurrent queries and a longer queue. Check slow queries, indexes, and write latency before adding pool capacity.
Tuning without creating a new problem
- Measure the current pool. Active processes, queue, max-children-reached, and slow requests over a full week.
- Size for memory. Estimate RAM per PHP process under your plugin load; leave room for MySQL, Redis, and the OS.
- Raise in steps. Increase max_children by 20–30%, then watch p95, memory, and database latency for 24 hours.
- Set max_requests. Recycle workers periodically to contain memory leaks; 300–1,000 is a common range.
- Enable the slow log. Find the request that holds a worker for 3 seconds; fix it instead of buying more workers.
- Separate background jobs. Run imports and renewals outside peak, or on a dedicated process where the platform allows.
The worker checklist
| Question | Where to look |
|---|---|
| Are all workers busy? | FPM status, active processes, listen queue |
| Is the database the real limit? | Slow query log, write latency, connection count |
| Is a plugin holding workers? | Slow log, profiling, query count per request |
| Is cron competing with traffic? | Action Scheduler, WP-Cron events, queue depth |
| Is the cache actually working? | Hit ratio, bypass rules, purge frequency |
| Is the plan limit a hard cap? | Host docs, support, load test at the ceiling |
A worker is a seat at the table. You can add seats, but if the kitchen is slow, everyone still waits.
Size for concurrency, watch p95, then check the cache.
Size the pool from concurrency, keep utilization below 70% at peak, separate background work, and watch p95 and queue depth rather than CPU. If you are buying a managed plan, ask for the worker count and the behavior at the limit. If you are running your own server, tune max_children against memory and database capacity, not against a generic recipe.
And before you buy more workers, check the cache. A site that invokes PHP for pages that could be cached is paying for concurrency it does not need.