MaxRequestWorkers is the single most misconfigured directive in Apache HTTPD. The failure pattern is always the same: someone picks a round number like 1000, deploys it on a 4GB server running mod_php children at 50MB RSS each, and the arithmetic (1000 x 50MB = 50GB of potential demand on 4GB of RAM) guarantees an OOM cascade the first time traffic reaches the limit. The setting feels like a performance knob. It is a memory budget.
Derive MaxRequestWorkers from two measured values: how much memory Apache is allowed to use, and how much memory one worker costs under real load. This article covers that derivation, the ServerLimit and ThreadsPerChild ceiling that silently caps whatever you configure, the differences between prefork, worker, and event MPMs that change what a “worker” costs, and how to validate the number after applying it.
If you are here because Apache logged AH00484: server reached MaxRequestWorkers setting, read this before raising the value. Raising it without checking memory turns a queuing problem into an OOM problem. For the incident-response side of that error, see Apache AH00484: server reached MaxRequestWorkers setting - worker pool exhausted.
What MaxRequestWorkers actually limits
MaxRequestWorkers sets the maximum number of simultaneous requests Apache will serve. It was called MaxClients before Apache 2.4; the old name still works, but the new one is more accurate because what it bounds is concurrent request processing, not TCP connections.
What a “worker” is depends on the MPM, and this changes the memory math completely:
- prefork: one child process per connection, one request at a time per process. Each worker is a full process carrying the entire httpd address space plus loaded modules. With mod_php, 10-50MB+ per process is typical. MaxRequestWorkers = maximum child processes.
- worker: multiple child processes, each running ThreadsPerChild threads. Each thread handles one connection. MaxRequestWorkers = maximum threads across all children.
- event (the 2.4 default): same thread model as worker, but keepalive connections are handled asynchronously by listener threads instead of occupying worker threads. MaxRequestWorkers still bounds active request processing, but idle keepalive connections no longer count against it.
Two consequences follow. First, on prefork every additional worker costs a full process worth of RSS. On worker and event, workers are threads sharing a process address space, so the marginal memory cost per worker is far lower and the binding constraint is usually ServerLimit arithmetic, not RAM. Second, on prefork and worker, idle keepalive connections hold workers until KeepAliveTimeout expires; a high keepalive timeout can exhaust the pool at low request rates, which looks like a capacity problem but is a configuration problem.
When all workers are busy, new connections queue in the kernel listen backlog (ListenBacklog, default 511, also capped by net.core.somaxconn). When the backlog fills, clients get connection refused. The degradation is cliff-edge: normal service at 99% utilization, queuing at 100%, refusals shortly after.
The sizing formula
MaxRequestWorkers = memory_budget_for_apache / memory_per_worker
Both inputs need definitions, and both are where teams go wrong.
Memory budget for Apache. Apache’s maximum theoretical memory (all workers active at peak RSS) should not exceed 70% of total RAM. The remaining 30% covers the OS, page cache (which matters for static file performance), and anything else on the box. On a shared host running a database or application server, subtract those first and apply the 70% rule to what is left, or lower the percentage further. On a dedicated web server, 70% of RAM is the ceiling, not the target.
Memory per worker. Measure it, do not assume it. Capture the RSS of real Apache children under real traffic:
# Per-child RSS, sorted worst-first (Debian: apache2; RHEL: httpd)
ps -C httpd -o pid,rss,vsz,cmd --sort=-rss 2>/dev/null || \
ps -C apache2 -o pid,rss,vsz,cmd --sort=-rss
# Average and count (note the braces: without them the awk only
# runs on the fallback branch)
{ ps -C httpd -o rss --no-headers 2>/dev/null || \
ps -C apache2 -o rss --no-headers; } | \
awk '{sum+=$1; count++} END {print "Avg RSS (KB):", sum/count, "Count:", count}'
Use the large end of the distribution, not the average, for worst-case sizing. Any process at more than 2x the average RSS indicates a memory-heavy request path, and that path is what you will hit at peak. For prefork with mod_php and no measurements yet, 50-100MB per child is a reasonable starting estimate, but validate it before trusting the derived number.
RSS overstates true per-process cost because it counts shared library pages in every process. PSS (Proportional Set Size, via smem or /proc/<pid>/smaps_rollup) is more accurate. Sizing from RSS gives a conservative number, which is the safe direction to err.
Worked example: 4GB RAM, dedicated server, mod_php prefork children measured at 50MB RSS at the heavy end. Budget is 4GB x 0.70 = 2.8GB. MaxRequestWorkers = 2800MB / 50MB = 56. Not 1000. If that feels low, the fix is reducing per-worker memory (PHP-FPM instead of mod_php is the usual big win) or adding RAM, not editing the number upward.
flowchart TD
A[Total RAM] --> B[Subtract OS and other services]
B --> C[Apply 70% ceiling = Apache budget]
D[Measure per-worker RSS under load] --> E[Use heavy end, not average]
C --> F[MaxRequestWorkers = budget / per-worker RSS]
E --> F
F --> G{Fits within ServerLimit x ThreadsPerChild?}
G -- yes --> H[Apply, graceful reload OK]
G -- no --> I[Raise ServerLimit first, full restart required]ServerLimit and ThreadsPerChild: the hidden ceiling
MaxRequestWorkers does not act alone. The scoreboard is a fixed-size shared memory segment allocated at startup, sized by ServerLimit (max child processes) multiplied by ThreadsPerChild (threads per process). MaxRequestWorkers cannot exceed that product.
This produces two operational traps:
- Silent capping. If you set MaxRequestWorkers above ServerLimit x ThreadsPerChild, Apache reduces it at startup and logs a warning (AH00180). If nobody reads startup logs, the server runs with a lower limit than the config says. server-status does not expose MaxRequestWorkers or ServerLimit, so the running value is invisible unless you check the config and the startup log together.
- The wrong restart. MaxRequestWorkers can be changed with a graceful restart. ServerLimit and ThreadLimit cannot; raising them requires a full stop and start. Operators raise ServerLimit, run
apachectl graceful, and wonder why AH00484 keeps firing. The change never took effect.
For threaded MPMs there is a third constraint: MaxRequestWorkers should be an integer multiple of ThreadsPerChild. If it is not, Apache rounds down at startup and logs a warning. With the default ThreadsPerChild of 25, a MaxRequestWorkers of 56 from the worked example above becomes 50 in practice. Either accept the rounding or set ThreadsPerChild explicitly and make the numbers line up.
Defaults matter because many servers run them. For prefork, the default MaxRequestWorkers is 256. For worker and event, the default is ServerLimit (16) x ThreadsPerChild (25) = 400. On prefork with mod_php, even the default 256 can be far too high for a small box: 256 x 50MB = 12.8GB of theoretical demand. The defaults are not safe; they are just numbers.
MPM-specific considerations
prefork. Memory is the binding constraint, full stop. Each connection costs a process. Derive MaxRequestWorkers from the formula and treat the result as a hard ceiling. KeepAliveTimeout deserves attention too: workers sit in K state holding a full process for an idle connection. A 15-60s keepalive timeout on prefork wastes a large fraction of the pool. Also set MaxConnectionsPerChild to a non-zero value (5000-10000) so leaky children get recycled before their RSS grows into your headroom; the default of 0 (unlimited) is wrong for mod_php or mod_perl deployments.
worker. Threads share the process address space, so per-worker marginal cost is small. The risk shifts: a single stuck backend can hold a thread indefinitely, and a segfault in one thread can kill the whole process and all its threads. Sizing is more about ThreadsPerChild and ServerLimit arithmetic than raw RAM, but total process RSS still counts against the 70% rule.
event. Same as worker for active requests, with the bonus that keepalive connections are offloaded to listener threads and tracked via ConnsAsyncKeepAlive instead of consuming workers. This decouples connection count from worker count, so event tolerates far more concurrent connections than the same MaxRequestWorkers on prefork. If you are on prefork purely out of habit and not because of a non-thread-safe module, moving to event is often a better fix than raising MaxRequestWorkers.
One cross-cutting note: if Apache runs under systemd, unit limits override Apache config. MemoryMax caps total memory regardless of your formula, and TasksMax caps processes plus threads, which can silently keep Apache below its configured MaxRequestWorkers. Check the unit file before blaming the Apache config.
Applying and validating the change
- Measure per-worker RSS under realistic load using the
pscommands above. Capture the heavy end, not just the average. - Compute the budget: 70% of RAM on a dedicated box, less on a shared one. Divide by per-worker RSS.
- Check the ceiling: does the result fit within ServerLimit x ThreadsPerChild? Round to a multiple of ThreadsPerChild on threaded MPMs.
- Adjust ServerLimit first if needed, and schedule a full restart for it. A graceful reload will not apply ServerLimit changes.
- Validate config with
apachectl configtestbefore any reload. configtest checks syntax only; it does not prove the server is healthy. - Apply:
apachectl gracefulfor MaxRequestWorkers-only changes, full restart when ServerLimit or ThreadLimit changed. During a graceful restart under load, old and new child generations overlap and memory usage can briefly approach double the steady state. Your 70% budget needs to absorb that, or restart during low traffic. - Read the startup log after applying. Look for AH00180 (MaxRequestWorkers reduced to fit ServerLimit) and the ThreadsPerChild rounding warning. What Apache logged is what you actually got.
After the change, verify against live behavior rather than trusting the config file:
# Worker utilization now
curl -s http://localhost/server-status?auto | grep -E "BusyWorkers|IdleWorkers"
# Any MaxRequestWorkers events since the change
grep "AH00484" /var/log/apache2/error.log | tail -20
Signals to watch after tuning
| Signal | Why it matters | Warning sign |
|---|---|---|
| BusyWorkers / MaxRequestWorkers | Primary saturation ratio; cliff-edge at 100% | Sustained above 80% at peak; IdleWorkers at zero |
| AH00484 in error log | Apache explicitly reporting pool exhaustion | Any occurrence after retuning |
| Total Apache RSS vs budget | Confirms the memory math holds in production | Approaching 70% of RAM; any sustained swap |
| Per-child RSS trend | Detects leaks that invalidate your per-worker assumption | Monotonic growth over days with MaxConnectionsPerChild 0 |
Listen backlog (Recv-Q via ss -ltn) | Earliest sign workers cannot keep up | Sustained non-zero during normal traffic |
| Scoreboard state mix | Tells you why workers are held (W = backends, K = keepalive, R = slow clients) | Dominant non-idle state that is not W under load |
| Graceful restart overlap memory | Old plus new generations double up briefly | OOM or swap spikes correlated with reloads |
The tuning goal is boring: IdleWorkers never pinned at zero, no AH00484 in the log, total RSS comfortably inside the budget at peak, and per-child RSS flat over weeks. For the full signal inventory, see Apache HTTPD monitoring checklist: the signals every production web server needs.
How Netdata helps
Sizing MaxRequestWorkers is a measurement problem, and the measurements are exactly what most teams collect once, by hand, and never again. The useful correlations:
- Per-process RSS over time, so the per-worker cost in your formula reflects weeks of real traffic, including the heavy endpoints, rather than one afternoon’s
pssnapshot. - BusyWorkers and IdleWorkers as a time series, so you can see peak utilization against the configured limit and spot the slow upward trend in peak BusyWorkers before it intersects MaxRequestWorkers.
- Total Apache memory against system RAM and swap, validating that MaxRequestWorkers x worst-case RSS stays under the 70% ceiling and catching graceful-restart memory overlaps.
- Scoreboard state distribution, which tells you whether workers are held by backends (W), keepalive (K), or slow clients (R), because the right fix for each is different and only one of them is “raise MaxRequestWorkers”.
- Error log events like AH00484, correlated on the same timeline as utilization and memory, so you can confirm whether the pool limit or the memory budget is the actual binding constraint.
Netdata’s Apache HTTP Server monitoring with Netdata brings these signals together with per-second metrics and ML anomaly detection.






