Apache serving static content is almost never CPU-bound. The request path (accept, read, sendfile, log) is cheap. When httpd processes start eating cores, the cause is nearly always something doing per-request or per-connection computation: TLS handshakes, mod_deflate compression, mod_rewrite regex evaluation, or mod_security rule processing.
The symptom is gradual, not a cliff. Latency creeps up as CPU saturates. Unlike worker exhaustion, which fails hard at 100% utilization, CPU saturation degrades service progressively, which makes it easy to ignore until p99 latency is unacceptable.
The other trap is tooling. mod_status reports a CPULoad value averaged over the entire server uptime. On a server that has been running for weeks, a current 100% CPU storm barely moves it. If you are using CPULoad to decide whether you have a CPU problem right now, you are looking at the wrong number.
What this means
CPU saturation means request latency is bounded by compute, not by workers, memory, or backends. Workers are busy doing actual work, not waiting. The scoreboard will show W states, but unlike a slow-backend cascade, CPU is elevated and backend health is fine.
Two distinct shapes:
- All children hot, proportional to traffic. Something makes every request or every connection expensive. Usual suspects: TLS handshakes without session resumption, mod_deflate at a high compression level, mod_security with a heavy ruleset.
- One child pegged at 100%, others normal. A single CPU-bound request spinning. The classic cause is a mod_rewrite redirect loop, where a rule rewrites a URL that matches the same rule again. One runaway child barely shows in total system CPU on a multi-core box, but that request never completes and the core is gone.
High system CPU (kernel time) rather than user CPU points at syscall volume: high new-connection rate, many small I/O operations, context switching. On Apache this most often means connection churn, which ties back to TLS handshakes and short keepalive timeouts.
flowchart TD
A[High Apache CPU] --> B{One child at 100%?}
B -->|yes| C[mod_rewrite redirect loop]
B -->|no| D{User or system CPU?}
D -->|system| G[Connection churn: check new-connection rate and TLS]
D -->|user| E{CPU per request rising?}
E -->|yes| F[mod_security or mod_deflate or regex cost]
G --> H{Session resumption working?}
H -->|no| I[Full handshake on every connection]
H -->|yes| J[Connection rate itself too high: keepalive, HTTP/2]Common causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| TLS full handshakes on every connection | CPU scaling with new-connection rate to 443, high system CPU, latency elevated on first request per connection | Test resumption with openssl s_client -reconnect; check SSLSessionCache config |
| mod_deflate compression | User CPU scales with bytes served; large text responses are expensive | DeflateCompressionLevel; which content types are being compressed |
| mod_rewrite redirect loop | Exactly one child at 100% CPU on one core, request never completes, same URL repeating in access log | ps for the hot child; find the looping URL in the access log |
| Complex mod_rewrite regex | Elevated CPU per request, proportional to request rate, all children similar | Rule count and regex complexity; .htaccess vs main config |
| mod_security rule processing | CPU per request high even at moderate traffic; worse on POSTs and large bodies | Rule set size; whether response body inspection is on |
Quick checks
All read-only and safe on a live server.
# Per-process CPU, hottest first (Debian name fallback included)
ps -C httpd -o pid,%cpu,cputime --sort=-%cpu 2>/dev/null | head || \
ps -C apache2 -o pid,%cpu,cputime --sort=-%cpu | head
One child at 100% with everyone else near zero is the rewrite-loop signature. All children equally hot is a per-request or per-connection cost.
# User vs system CPU split for all Apache processes
top -bn1 -p $(pgrep -d',' httpd 2>/dev/null || pgrep -d',' apache2) | tail -n +8
High %sy relative to %us points at connection churn and syscall volume, not rule processing.
# New inbound TCP connection rate: PassiveOpens delta over 10s
awk '/^Tcp:/ {print $7}' /proc/net/snmp
sleep 10
awk '/^Tcp:/ {print $7}' /proc/net/snmp
PassiveOpens is the kernel counter for accepted inbound connections. Divide the delta by 10 for connections per second, and compare against your request rate. If connections per second is close to requests per second, clients are not reusing connections, and every connection costs a TLS handshake. This counts all listeners; on a dedicated web host that is overwhelmingly 80/443 traffic.
# Test TLS session resumption (use your real vhost name for SNI)
openssl s_client -connect localhost:443 -servername example.com -reconnect 2>/dev/null | grep -c "Reused"
Zero (or near zero) “Reused” lines means resumption is broken and every connection pays the full handshake cost.
# Request rate from the Total Accesses delta (not ReqPerSec, which is a lifetime average)
curl -s http://localhost/server-status?auto | grep "Total Accesses:"
sleep 30
curl -s http://localhost/server-status?auto | grep "Total Accesses:"
# Look for the looping URL: same path repeated at high frequency
tail -2000 /var/log/apache2/access.log 2>/dev/null | awk '{print $7}' | sort | uniq -c | sort -rn | head || \
tail -2000 /var/log/httpd/access_log | awk '{print $7}' | sort | uniq -c | sort -rn | head
How to diagnose it
Establish the shape. Run the
pscheck above. One hot child means a runaway request (step 4). Uniformly hot children mean a systemic per-request cost (steps 2, 3, 5, 6).Compute CPU per request. Total CPU time consumed over an interval divided by requests completed in the same interval (from the
Total Accessesdelta). This number should be stable over time. If it is rising, per-request cost is growing: new mod_security rules, more content being compressed, more rewrite rules, or a traffic-mix shift toward expensive endpoints. If it is flat but total CPU is up, volume is up.Check the connection side. If system CPU is high and the new-connection rate is high relative to request rate, test session resumption with
openssl s_client -reconnect. Zero reuse means every connection is a full handshake. Full handshakes with RSA key exchange are roughly an order of magnitude more CPU than a resumed session; on a busy HTTPS site this dominates everything else.Hunt the single hot child. If one child is pegged, find what it is serving. The full
server-statuspage (not?auto) shows the current request per slot. Correlate with the access log: a redirect loop shows the same URL (or its redirect target) repeating at high frequency. The usual mechanism is a mod_rewrite loop in per-directory context (.htaccess): the[L]flag stops the current pass, but Apache reinjects the rewritten URI and runs the ruleset again from the top, so a rule that matches its own output loops forever.Profile mod_deflate. If user CPU tracks bytes served, check what you are compressing and at what level.
DeflateCompressionLeveltrades CPU for ratio; higher levels cost more CPU for diminishing size returns. Also check whether you are compressing content that is already compressed (images, video, archives), which burns CPU for zero benefit.Profile mod_security. If CPU per request is high on a site running mod_security with the OWASP CRS, the ruleset itself is the cost. Check whether response body inspection is enabled; it is a major additional cost. The failure mode here is gradual: every rule you add makes every request slightly more expensive, and there is no single moment it breaks.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
| CPU per request (CPU time / request delta) | Stable per-request cost is the baseline; growth means a rule, filter, or module got more expensive | Upward trend over days |
| New-connection rate vs request rate | Reveals whether handshakes or request processing dominates CPU | Connection rate approaching request rate (no connection reuse) |
| TLS resumption rate | Full handshakes are the most CPU-intensive thing Apache does | Resumption near zero, or dropping after a config change |
| User vs system CPU split | Separates rule/compression cost (user) from syscall and connection churn (system) | System CPU >30% of total |
| Per-child CPU distribution | A single hot child is a different incident than uniform load | One child at 100% on one core |
| Total Apache CPU vs available cores | You want headroom before latency grows unboundedly | Sustained >70-80% of cores at peak |
Scoreboard W states with high CPU | Distinguishes “workers computing” from “workers waiting on backend” | Many W plus high CPU (backend problems show many W with normal CPU) |
Fixes
TLS handshake cost
Enable and share a session cache. SSLSessionCache defaults to none, which means session-ID resumption does not work at all unless you configure it. Use the shared-memory cache so it works across child processes, for example SSLSessionCache shmcb:/path/to/cache(size). Without a shared cache, a client reconnecting to a different child pays a full handshake. Session tickets (RFC 5077) work across children without a shared cache, but the ticket key is static until restart, so after every restart every client does a full handshake anyway. After any crash or restart the cache is empty: expect a CPU burst of full handshakes during warmup.
Prefer ECDSA certificates. ECDSA handshakes are significantly cheaper than RSA-2048 for the server. If you are on RSA certs and CPU-bound on handshakes, this is one of the largest single wins available.
Reduce connection churn. Longer KeepAliveTimeout and HTTP/2 both cut the number of connections, and therefore handshakes, per unit of traffic. HTTP/2 multiplexing means one connection carries many requests.
Watch your OpenSSL version. Apache links against the system OpenSSL, so OpenSSL’s handshake cost is Apache’s handshake cost. There are independent reports of a measurable TLS handshake throughput regression in OpenSSL 3.5+ related to default key-share behavior. If CPU jumped after an OS upgrade that bumped OpenSSL, check the version before blaming Apache.
mod_rewrite loops and regex cost
Fix the loop with [END], not [L]. In per-directory context (.htaccess), [L] only stops the current pass; the rewritten URI is reinjected and the ruleset runs again. The [END] flag (Apache 2.4+) terminates rewrite processing entirely and is the correct way to stop loops. A rule that rewrites a path that still matches its own pattern needs either [END], a RewriteCond guard that excludes the rewritten form, or a redesign.
Move rules out of .htaccess. Besides enabling the reinjection loop behavior, AllowOverride costs per-request filesystem stat() calls on every directory component of every URL. Put the rules in the main config inside <Directory> blocks and set AllowOverride None. This removes both the loop mechanism and the I/O tax.
Simplify hot-path regex. Rules evaluated per request with expensive backtracking patterns are a steady CPU tax at scale. Anchor patterns, avoid nested quantifiers, and order rules so cheap matches short-circuit first.
mod_deflate cost
Lower DeflateCompressionLevel. Higher levels buy smaller output with more CPU. The default level is not stated numerically in the official docs; third-party sources say 6 or 7. If you are CPU-bound, drop toward the low end and measure the bandwidth increase; for most text content the size difference between level 1 and level 9 is far smaller than the CPU difference.
Compress only what benefits. Restrict compression to text content types (HTML, CSS, JS, JSON, XML). Compressing JPEG, video, or archives costs CPU and yields nothing. Note that mod_deflate buffers output to compress it, which raises time to first byte; that is expected behavior, not a separate bug.
mod_security cost
Audit the ruleset against actual traffic. Every active rule is evaluated per request. Tune out rules that never fire on your traffic profile and disable categories you do not need. Rule cost is a ratchet: it only grows unless you deliberately prune.
Reconsider response body inspection. Inspecting response bodies roughly doubles the inspection work and is a common source of a large latency and CPU jump when a ruleset is first enabled. Disable it unless you have a specific need.
Check for known pathological modes. There are reports of specific request patterns causing outsized CPU when SecRuleEngine is DetectionOnly rather than On, and of the persistent collection storage growing unbounded and progressively slowing rule evaluation. If CPU grows slowly over weeks without a config change, check the age and size of the collection data and test whether cycling it restores performance.
Prevention
- Track CPU per request as a first-class metric. It is the earliest signal for every cause in this article except the rewrite loop. Trend it and alert on drift, not on absolute CPU.
- Load-test TLS configuration changes. Turning off session tickets, rotating to a new certificate type, or changing the session cache is a CPU change, not just a security change. Measure handshake cost before and after.
- Test rewrite rules against their own output. Any rule whose substitution can match its own pattern is a loop candidate in per-directory context. Prefer
[END]and explicitRewriteCondguards. - Keep 30% CPU headroom at peak. CPU degradation is gradual and invisible until it is not. Keep Apache CPU under 70% of available cores at the highest normal traffic period.
- Expect the cold-start handshake burst. After any restart, session caches are empty and every connection is a full handshake. Do not size CPU headroom assuming warm-cache behavior right after a deploy.
How Netdata helps
- Netdata charts per-process CPU for httpd/apache2 children, which makes the “one hot child” rewrite-loop signature visible immediately instead of hidden inside a total-CPU average.
- It tracks
Total Accessesas a rate, so you can derive CPU per request and watch it drift, which is the leading indicator for mod_security, mod_deflate, and regex cost growth. - Connection metrics on the listener (and
ConnsTotal/ async connection counters on the event MPM) let you compare new-connection rate against request rate to see whether handshakes or request processing dominates. - The scoreboard state distribution separates CPU-bound workers (many
Wwith high CPU) from backend-bound workers (manyWwith normal CPU), which decides which guide you need next. - Per-second collection catches the post-restart TLS handshake burst and short-lived CPU spikes that interval-based polling averages away.
Netdata’s Apache HTTP Server monitoring with Netdata brings these signals together with per-second metrics and ML anomaly detection.
Related guides
- How Apache HTTPD actually works in production: a mental model for operators
- Apache BusyWorkers and IdleWorkers: reading worker utilization from mod_status
- Apache 503 Service Unavailable: worker exhaustion versus proxy pool exhaustion
- Apache 504 Gateway Timeout: slow backends, ProxyTimeout, and worker pile-up
- Apache backend response time: telling ‘Apache is slow’ from ’the backend is slow’
- Apache 5xx error rate: 500 vs 502 vs 503 vs 504 and what each one means
- Apache 500 Internal Server Error: modules, handlers, and misconfiguration
- Apache 502 Bad Gateway: a backend that returned an invalid response
- Apache balancer member in error state: reading balancer-manager and failover
- Apache CLOSE_WAIT and TIME_WAIT: connection leaks versus normal churn
- Apache error log monitoring: severity levels, AH codes, and what to alert on
- Apache AH00558: Could not reliably determine the server’s fully qualified domain name






