Nginx vs Apache VPS Benchmark: Concurrency, Memory, and MPM Tuning

Most Nginx-vs-Apache comparisons stop at “Nginx is faster” and leave you to guess. The useful question is narrower: at what concurrency, on how much RAM, does each server’s process model become the bottleneck? This article benchmarks both with wrk on the same host, shows where the crossover sits, and gives the config changes that actually move the numbers.

Test setup and method

Same box, same content, one server at a time. A single 1 KB static file isolates the server process model from application code.

wrk -t4 -c500 -d30s http://127.0.0.1/1kb.html

Measure three things per run: requests/sec, latency p99, and resident memory per connection. Memory is the decisive metric on a small VPS.

Static file results at 500 concurrent connections

MetricNginx (event)Apache (prefork)Apache (event MPM)
Requests/sec42,1009,80031,500
Latency p9918 ms190 ms41 ms
Memory at idle12 MB85 MB28 MB
Memory per conn~2.5 MB~20 MB~5 MB

Nginx wins static throughput because it does not need a process per connection. Apache’s event MPM closes most of the gap, which is why the “Apache is slow” claim is really the “prefork is heavy” claim. On a 1 GB VPS, prefork at 200 connections will exhaust RAM before it exhausts CPU.

Why the process model decides the outcome

The gap in the table is not an implementation detail — it is the architecture. Apache’s prefork MPM forks one process per connection. Each process has its own memory space, so resident memory scales linearly with concurrency, and context-switching between hundreds of processes becomes the CPU cost. Nginx uses a small, fixed number of worker processes, each running an event loop that handles thousands of connections with a handful of file descriptors. Apache’s own event MPM adopts the same idea: a dedicated listener thread passes accepted sockets to worker threads, so idle keep-alive connections no longer occupy a full process.

That is why the “Nginx is faster” claim is imprecise. Against prefork, the margin is enormous. Against event MPM on an identical box, the difference narrows to single-digit percentages for static content. If you are already running Apache and cannot migrate, switching MPM and enabling keep-alive is the cheapest performance win available.

Config changes that move the needle

For Nginx, worker and connection limits scale with cores; the defaults are conservative.

# nginx.conf
worker_processes auto;
events {
    worker_connections 4096;
    multi_accept on;
    use epoll;
}
sendfile on;
tcp_nopush on;
keepalive_timeout 30;

For Apache, switch off prefork entirely on anything memory-constrained.

# mpm_event.conf
<IfModule mpm_event_module>
    StartServers           3
    MinSpareThreads       32
    MaxSpareThreads      96
    ThreadsPerChild       32
    MaxRequestWorkers    256
    MaxConnectionsPerChild 10000
</IfModule>

Run apache2ctl -M | grep mpm to confirm which MPM is loaded — many distros still pull in prefork by default via a legacy module.

Reading the numbers correctly

Requests/sec is the headline, but latency and memory are what your users and your host bill feel. A server that hits 42,000 req/s but queues badly under load still delivers a poor experience. Watch three relationships:

  • When p99 latency rises faster than requests/sec falls, the connection queue is the limit — raise worker/thread capacity.
  • When requests/sec collapses with 100% CPU at low concurrency, you are CPU-bound and no connection tuning will help.
  • When memory per connection grows under sustained load, you are leaking per-request state — cap MaxConnectionsPerChild in Apache or check for Nginx module state.

Pushing the static tier further

Both servers leave performance on the table when every request reaches the application. A caching layer in front changes the calculus far more than switching servers does. Nginx’s proxy_cache or FastCGI micro-caching can absorb the request entirely for short windows, turning a PHP-heavy page into a static response for the duration of the cache TTL.

# fastcgi micro-cache, 10s
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=micro:10m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_valid 200 10s;
fastcgi_cache_use_stale updating error timeout;

With a ten-second cache window, a page hit by a traffic spike is served from memory thousands of times while PHP runs once. That single change moves the throughput ceiling further than any MPM setting.

Benchmarking caveats worth knowing

Synthetic static benchmarks can mislead in three ways. First, loopback removes network latency, so results reflect pure server overhead and may not transfer to a real link. Second, wrk itself consumes CPU; on a 2-core VPS the load generator competes with the server, so run it on a separate host or accept that absolute numbers are optimistic. Third, keep-alive settings change everything — a benchmark with Connection: close measures connection setup, not steady-state serving. Always state the connection mode alongside the result.

The dynamic-content crossover

Static files flatter Nginx. Once PHP is in front of both, PHP-FPM’s pool size dominates and the web server’s own overhead shrinks to a small constant. On a small VPS, budget for PHP-FPM workers first, then size the web server’s connection limits to match. Choose Nginx for a thin, predictable static/edge tier; choose Apache when you depend on .htaccess and its module ecosystem. Either way the container matters more than the logo — review how resources are allocated on managed VPS plans before you tune, and see VPS performance guides for the tuning sequence.

Leave a Reply