The idea: who holds a connection while it waits
A web server spends most of a request's life waiting: for the rest of the request to arrive, for the database, for the client to read the answer. The three classic server designs differ in what they park on a connection during that wait. Prefork parks a whole process on it, a thread pool parks a thread on it, and an event loop parks nothing but a file descriptor in epoll.
The canvas sends the same requests to three servers at once. Each lane has the server's workers as timelines (one cell per 10 ms), the kernel's listen backlog where accepted-by-TCP but not yet accept()ed connections wait, the network, and counters for memory, requests served or lost, latency and the longest backlog. Each lane gets one CPU core, so the only difference is how the server is built. A normal request needs 10 ms of CPU, then 30 ms waiting for the database, then 10 ms of CPU to write the answer.
Prefork: a process per connection
A master process opens the listening socket and then fork()s worker processes that inherit it. Each worker loops: accept() one connection, serve it to the end, close it, accept() again. Apache's mpm_prefork (MaxRequestWorkers), PHP-FPM (pm.max_children) and gunicorn's default sync workers (--workers) all work this way. The code inside a worker can be plain blocking code, and it does not need to be thread-safe, which is why PHP and most Python and Ruby apps run like this. The price is memory: every worker has its own copy of the interpreter and the app, about 30 MB here, and the number of workers is the number of requests the server can hold at once. In Demo 1 four workers hold r1 to r4 while they wait for the database, so r5 and r6 wait in the backlog until a worker is free.
Threads: a thread per connection
A thread server runs one process with a pool of threads; each thread takes one connection and serves it with the same blocking code. Tomcat's classic connector (maxThreads, default 200) and Apache's mpm_worker work this way. Threads share the process's heap, so a thread costs about its stack (1 MB is a common default for Java) instead of a whole process: the same four workers cost 64 MB instead of 130 MB. The timing in Demo 1 is identical to prefork, because a thread still sits idle while its request waits for the database. Sharing has two costs: the app code must be thread-safe, and anything that kills the process kills every connection in it.
Events: one loop for every connection
An event-driven server registers every socket with epoll and runs one loop: wait for whichever sockets are ready, run a little code for each, go back to waiting. A request waiting for the database is just a pending callback, so the loop serves other connections in the meantime and accepts every new connection at once (Demo 1: the backlog never holds anyone). nginx runs one such loop per worker process (worker_processes auto, one per core, each with up to worker_connections), Node.js runs one per process. An open connection costs a few KB of buffers, not a thread or a process.
| demo | prefork | threads | events |
|---|---|---|---|
| 1: burst of 6, 4 workers | 130 MB, avg 80 ms, backlog 2 | 64 MB, avg 80 ms, backlog 2 | 20 MB, avg 85 ms, backlog 0 |
| 2: 4 slow clients + 2 | served 0/2 | served 0/2 | served 2/2, avg 50 ms |
| 3: one 80 ms CPU burst | avg 88 ms | avg 88 ms | avg 115 ms, max 130 ms |
| 4: a crashing request | served 3/4, lost 1 | served 1/4, lost 3 | served 1/4, lost 3 |
| 5: burst, 2 workers | avg 100 ms, max 150 ms, backlog 4 | avg 100 ms, max 150 ms, backlog 4 | avg 85 ms, max 100 ms, backlog 0 |
Slow clients hold workers hostage
A worker that serves a connection is busy for as long as the client is, not just for as long as the request is. A client on a slow mobile link, a keep-alive connection that sends nothing, or a Slowloris attacker that sends one header byte every few seconds each hold a worker. In Demo 2 four slow clients take all four workers of prefork and threads, and r1 and r2 never leave the backlog (Apache waits up to Timeout, 300 s by default). The event loop does not notice: a slow connection is one more file descriptor that is rarely ready. This is why Apache, PHP-FPM and gunicorn are usually put behind nginx, which buffers the slow client and hands the app server only complete requests, and why Apache later added mpm_event, which parks idle keep-alive connections in an event loop instead of in a thread. See how nginx handles a request and how Tomcat handles a request, whose NIO connector does the same trick with a Poller thread.
CPU-heavy work: the loop cannot be preempted
Prefork workers and threads are scheduled by the kernel, which takes the core away from a long computation every time slice and gives it to whoever is ready. An event loop runs each callback to its end: nothing interrupts it. In Demo 3 r1 needs 80 ms of CPU. Prefork and threads slice it with r2, r3 and r4, so the short requests are barely held up (average 88 ms over all four, though r1 itself stretches to 140 ms); the event loop runs r1's 80 ms in one go while r2, r3 and r4 sit ready: average 115 ms. Event-loop servers send such work elsewhere: Node.js to worker_threads or the libuv thread pool, nginx to an upstream app server. See preemptive vs cooperative scheduling for the same effect in the scheduler, and the Node.js event loop.
Crashes: isolation is the reason prefork survives
Processes do not share memory, so a segfault in a PHP extension or a memory leak in one worker stays in that worker. In Demo 4 r2 kills prefork's W2; the master notices (SIGCHLD), forks a replacement, and because the master owns the listening socket, r3 and r4 queue in the backlog as usual: only r2 is lost. In the thread server and the event loop the same bug kills the one process, and with it r1, which was waiting for the database; r3 is refused while systemd restarts it. Prefork servers also recycle workers on purpose to contain leaks: pm.max_requests in PHP-FPM, --max-requests in gunicorn, MaxConnectionsPerChild in Apache. Process vs thread shows what fork and threads share underneath.
Which one, and the hybrids
| model | one connection costs | wins when | loses when |
|---|---|---|---|
| Prefork | a process (tens of MB) | code that is not thread-safe, crash and leak isolation, simple blocking code | many concurrent or slow connections: memory runs out first |
| Threads | a thread (a stack, ~1 MB) | blocking code with a few hundred concurrent requests, CPU-heavy handlers | thousands of idle connections; one crash takes the whole process |
| Events | a file descriptor (a few KB) | many connections, mostly idle or waiting on I/O: proxies, chat, APIs | CPU-heavy handlers or any blocking call: every connection on the loop waits |
Most production stacks mix them: nginx (events) in front of PHP-FPM or gunicorn (prefork), Tomcat's NIO connector (an event-driven Poller in front of a thread pool), gunicorn's gthread and async workers, Apache mpm_event, and Java virtual threads, which keep thread-per-request code but park waiting requests like an event loop. See thread pools, blocking vs non-blocking vs async I/O for the system calls underneath, and virtual threads.
What the page leaves out
Several cores: real prefork and thread servers run workers on every core, and nginx runs one event loop per core; here each lane has one core so the designs are compared on equal CPU. The thundering herd when many workers wait in accept(), SO_REUSEPORT and EPOLLEXCLUSIVE; dynamic pools (pm = dynamic, MinSpareServers) that fork and reap workers as load changes; copy-on-write sharing between forked workers (gunicorn --preload); keep-alive timeouts; backlog overflow (somaxconn) and the SYN queue; graceful reloads. Memory sizes, timings and the 20 ms restart are a rough scale, not measurements.
See where this page fits on the Servers & Event Loops mind map.