Apache performance tuning: MPMs, MaxRequestWorkers and memory
The single most important Apache setting is MaxRequestWorkers: how many requests the server works on at once. Set it too high and the machine runs out of RAM and starts swapping; the httpd documentation says a web server should never have to swap, because every request then slows to a crawl. Set it too low and visitors queue. This page shows how to pick an MPM, measure memory, and do the arithmetic, then covers the smaller settings and how to benchmark.
Pick the right MPM
An MPM (multi-processing module) decides how Apache uses processes and threads. Exactly one is loaded.
| MPM | How it works | Use it when |
|---|---|---|
| event | Several processes, many threads each. A listener thread looks after idle keep-alive connections, so threads are only busy while a request is processed. | Almost always. It is the default on Debian, Ubuntu, RHEL and Fedora. |
| worker | Like event, but each open connection holds a thread, including idle keep-alive time. | Rarely; event is the improved version. |
| prefork | Many single-threaded processes, one connection each. | Only for code that is not thread-safe, mainly mod_php. |
Check which one is running:
apachectl -V | grep -i mpm
Server MPM: event
# Debian/Ubuntu only:
a2query -M
event
If you still use mod_php, the best performance step is to move PHP to PHP-FPM and switch to event (see PHP with Apache). On Debian the PHP module's load file declares Depends: mpm_prefork, and installing libapache2-mod-php switches the server to prefork, so disable it first: sudo a2dismod php8.4 mpm_prefork && sudo a2enmod mpm_event, then restart.
How the limits fit together
- MaxRequestWorkers
- Requests served at the same time. Prefork: number of child processes. Event/worker: total number of threads. Extra connections wait in the kernel's listen queue.
- ThreadsPerChild
- Threads in each event/worker process. MaxRequestWorkers must be a whole multiple of it; Apache rounds down and logs a warning otherwise.
- ServerLimit
- Hard cap on processes. Event/worker need at least MaxRequestWorkers ÷ ThreadsPerChild; event uses extra slots for old processes that are finishing after a graceful restart.
- ThreadLimit
- Hard cap on ThreadsPerChild (default 64).
| Directive | event / worker | prefork |
|---|---|---|
| MaxRequestWorkers | 400 (16 × 25) | 256 |
| ServerLimit | 16 | 256 |
| ThreadsPerChild | 25 | – |
| ThreadLimit | 64 | – |
| StartServers | 3 | 5 |
| MaxConnectionsPerChild | 0 (never recycle) | 0 |
Debian and Ubuntu override these in /etc/apache2/mods-available/mpm_event.conf and mpm_prefork.conf, both with MaxRequestWorkers 150. On RHEL and Fedora the MPM is chosen in /etc/httpd/conf.modules.d/00-mpm.conf; put your sizing in a new file such as /etc/httpd/conf.d/mpm-tuning.conf.
Measure memory per process
Look at the server under normal load, not just after a restart, because processes grow as they handle requests:
ps -C apache2 -o rss= | awk '{n++; s+=$1} END {printf "%d processes, average %.0f MB\n", n, s/n/1024}'
# RHEL/Fedora: ps -C httpd ...
# PHP-FPM workers: ps -C php-fpm8.4 ... (Debian) or ps -C php-fpm ... (RHEL)
RSS (resident memory) counts shared libraries in every process, so the total is higher than real use. That makes the estimate conservative, which is what you want. Then decide how much RAM to keep for everything else on the machine: the operating system, the database, the page cache.
Worked example: prefork with mod_php
A server has 8 GB of RAM (8,192 MB). MariaDB and the system need about 2,048 MB. ps shows Apache children averaging 60 MB each.
- RAM for Apache: 8,192 − 2,048 = 6,144 MB
- Processes that fit: 6,144 ÷ 60 = 102.4, round down to 100
- Check: 100 × 60 = 6,000 MB, leaving 144 MB spare
<IfModule mpm_prefork_module>
StartServers 5
MinSpareServers 5
MaxSpareServers 10
MaxRequestWorkers 100
MaxConnectionsPerChild 0
</IfModule>
100 is below prefork's default ServerLimit of 256, so ServerLimit is not needed. If you ever need more than 256, set ServerLimit to the same value as MaxRequestWorkers.
Worked example: event with PHP-FPM
Same 8 GB server, now with event and PHP-FPM. Apache threads mostly wait on PHP or on slow clients, so Apache itself is light; the PHP-FPM pool is what uses memory. Suppose ps shows Apache processes at about 30 MB each and PHP-FPM workers at about 50 MB each, and you want Apache to accept 400 simultaneous requests.
- Apache processes: 400 ÷ 25 threads = 16. Allow 4 more for processes finishing after a reload: ServerLimit 20.
- Apache worst case: 20 × 30 = 600 MB
- RAM left for PHP: 8,192 − 2,048 − 600 = 5,544 MB
- PHP workers that fit: 5,544 ÷ 50 = 110.9, round down to 110
<IfModule mpm_event_module>
StartServers 2
ServerLimit 20
ThreadLimit 64
ThreadsPerChild 25
MaxRequestWorkers 400
MinSpareThreads 25
MaxSpareThreads 75
MaxConnectionsPerChild 0
</IfModule>
In the PHP-FPM pool file set pm.max_children = 110; that is the real limit on PHP requests at once. MaxSpareThreads must be at least MinSpareThreads + ThreadsPerChild (25 + 25 = 50), so 75 is fine.
Apply the change safely
ServerLimit and ThreadLimit only change on a full stop and start, so restart rather than reload:
sudo apachectl configtest
sudo systemctl restart apache2 # RHEL/Fedora: sudo systemctl restart httpd
# restart drops connections in progress; do it at a quiet time
Then watch the error log. These messages mean you really hit the limit:
AH00484: server reached MaxRequestWorkers setting, consider raising the MaxRequestWorkers setting (event)
AH00286: ... (worker) AH00161: ... (prefork)
AH03490: scoreboard is full, not at MaxRequestWorkers.Increase ServerLimit. (event)
Raise MaxRequestWorkers only if the arithmetic still fits in RAM. If it does not, the fix is more memory, lighter processes (PHP-FPM, fewer modules), caching, or slow backends made faster. Watch live worker use with mod_status.
KeepAlive, Timeout and lookups
- KeepAlive On (default) lets a browser reuse one connection for many requests.
KeepAliveTimeoutdefaults to 5 seconds andMaxKeepAliveRequeststo 100. With event, idle keep-alive connections cost no worker thread. With prefork, each idle connection holds a whole process, so keep the timeout short (2–5 seconds). The documentation advises never going above about 60. - Timeout defaults to 60 seconds of waiting for network I/O. Lowering it frees workers held by stalled clients. For slow-client attacks use mod_reqtimeout (see DDoS and bots).
- HostnameLookups Off is the default; keep it.
Require hostrules make Apache do a reverse and a forward DNS lookup for the client, whatever HostnameLookups says, so preferRequire ip. - AllowOverride None stops Apache looking for .htaccess files in every directory of the path on every request.
- Options FollowSymLinks is cheaper than
SymLinksIfOwnerMatch, which adds anlstat()per path component.
Unload modules you do not use
Each loaded module adds memory to every process. List them with apachectl -M. On Debian/Ubuntu remove one with sudo a2dismod name; on RHEL/Fedora comment out its LoadModule line in /etc/httpd/conf.modules.d/. Run apachectl configtest after each change: a module you thought was unused may be needed by a directive somewhere.
Benchmark before and after
Run load tests from a different machine (the tool uses CPU too), against a staging copy rather than production, and change one setting at a time.
# ApacheBench (apache2-utils on Debian, httpd-tools on RHEL)
ab -n 5000 -c 50 -k https://staging.example.com/
# wrk: 4 threads, 100 open connections, 30 seconds
wrk -t4 -c100 -d30s https://staging.example.com/
# h2load (nghttp2): HTTP/2, 100 clients, 10 streams each
h2load -n 10000 -c 100 -m 10 https://staging.example.com/
In ab's report, check Failed requests and Non-2xx responses first, then Requests per second and Time per request. For dynamic pages whose length varies, add -l so ab does not count them as failures. ab sends HTTP/1.0 requests, so use h2load to test HTTP/2. During a test, watch free -m on the server: any swapping means MaxRequestWorkers (or pm.max_children) is too high.
Mistakes to avoid
- Raising MaxRequestWorkers without doing the memory arithmetic.
- Changing ServerLimit or ThreadLimit and only reloading: the new value is ignored until a restart.
- Setting MaxRequestWorkers to a value that is not a multiple of ThreadsPerChild.
- Keeping prefork after moving PHP to PHP-FPM.
- Benchmarking from the server itself, or trusting one short run.
- Tuning before enabling compression and caching, which often help more.
Sources
- Apache Performance Tuning, Apache HTTP Server 2.4 documentation
- Apache MPM Common Directives, Apache HTTP Server 2.4 documentation
- Apache MPM event, Apache HTTP Server 2.4 documentation
- Apache MPM worker, Apache HTTP Server 2.4 documentation
- Apache MPM prefork, Apache HTTP Server 2.4 documentation
- Core features (KeepAlive, Timeout, HostnameLookups), Apache HTTP Server 2.4 documentation
- mod_authz_host (Require host), Apache HTTP Server 2.4 documentation
- ab - Apache HTTP server benchmarking tool, Apache HTTP Server 2.4 documentation
- Event, worker and prefork MPM source (AH00484, AH00286, AH00161, AH03490), Apache httpd 2.4.x branch
- Debian apache2 mods-available (mpm_event.conf, mpm_prefork.conf), sources.debian.org
- Debian php8.4 packaging (php.load, libapache2-mod-php), sources.debian.org
- Fedora httpd 00-mpm.conf, src.fedoraproject.org
- PHP-FPM configuration (pm.max_children), PHP Manual
- wrk HTTP benchmarking tool, GitHub
- h2load(1), nghttp2 documentation