An educational web server written from scratch in C, with an epoll event loop.
# Compile
make clean && make
# Run
./bin/web-server [OPTIONS]Options:
-p, --port PORT Port to open (default to 8080)
-d, --dir DIRECTORY Root directory
-v, --verbose Verbose logging (log incoming requests)
-h, --help Show this help
Example:
./bin/web-server -p 3000 -d /var/www -v- Central
epollevent loop. - Persistent connections (
HTTP/1.1 Keep-Alive), client sockets are reused across requests. - Handle
GETrequests, file extensions to MIME types mapping (.html,.css,.png,.jpg,.ico,.pdf). - Zero-copy file transfer using Linux
sendfileandTCP_CORK.
Below are benchmarking results performed on my laptop, using wrk. The server serves static HTML files from the HTML5 UP repository, specifically the aerial template. Server runs without verbose flag, because logging is a major bottleneck in my implementation. Nginx baseline runs on a similar configuration with 1 worker process and logging disabled (benchmarks/nginx.conf). Raw benchmark outputs are logged in benchmarks/RESULTS.md.
Hardware specs: 13th Gen Intel Core i5-13500HX, 16GB RAM
| Connections | Threads | Server | Throughput (req/s) | Avg Latency | p50 Latency | p99 Latency |
|---|---|---|---|---|---|---|
| 100 | 4 | This Server | 187,396.16 | 532.95us | 519.00us | 759.00us |
| 100 | 4 | Nginx | 148,339.97 | 675.23us | 667.00us | 795.00us |
| 1,000 | 8 | This Server | 159,930.76 | 6.24ms | 6.08ms | 9.29ms |
| 1,000 | 8 | Nginx | 140,214.76 | 8.94ms | 7.14ms | 8.77ms |
| 4,000 | 8 | This Server | 125,226.33 | 31.84ms | 31.54ms | 35.79ms |
| 4,000 | 8 | Nginx | 120,634.38 | 31.46ms | 31.17ms | 35.44ms |
Profiling with perf at 1,000 concurrent connections (perf/report.txt) shows that ~87% of CPU time is spent inside Linux system calls, with each request executing ~10 syscalls. Toggling TCP_CORK via setsockopt accounts for nearly half of all cycles because it triggers kernel TCP frame transmission and loopback networking:
| Syscall / Subsystem | % CPU Cycles | Role in Request Path |
|---|---|---|
setsockopt |
47.7% | Toggling TCP_CORK (pushes frames and processes loopback ACKs) |
sendfile |
8.7% | Zero-copy transmission of the static file body |
epoll_ctl |
8.4% | Toggling connection flags (EPOLLIN EPOLLOUT) |
open / openat |
7.4% | Opening file descriptor per GET request |
epoll_wait |
7.0% | Polling the event loop |
write |
6.3% | Writing HTTP response headers to socket |
read |
5.7% | Reading incoming HTTP request into buffer |
In user space (~13% total), prepare_headers() is the main consumer (9.5%), spent on snprintf() header formatting and path resolution.
- The server runs on a single event loop thread and cannot utilize multiple CPU cores. Throughput is bounded by the single-core execution speed.
- Each request incurs ~10 system calls. Repeatedly calling
open()andfstat()per request consumes ~7.4% of CPU cycles that could be eliminated with metadata and descriptor caching. - Verbose logging (
-v) writes synchronously tostdout/stderr, blocking the event loop on slow terminal I/O and causing throughput drops under load. - Persistent connections are not timed out. An idle client holds its descriptor slot in
clients[MAX_FDS]indefinitely. - Request parsing is naive and simple. URI paths are appended directly to the root directory without canonicalization, leaving the server without path traversal (
../) filtering or TLS/HTTPS support.
- Use
SO_REUSEPORTto distribute incoming connections across multiple worker processes or threads to saturate all CPU cores. - Implement an in-memory LRU cache for open file descriptors and pre-computed HTTP headers (Content-Length, MIME type) to bypass repeated
open(),fstat(), andsnprintf()calls. - Migrate client sockets to edge-triggered mode to reduce repeated
epoll_ctltoggling betweenEPOLLINandEPOLLOUT. - Enforce idle keep-alive timeouts and request read timeouts, preventing connection slot exhaustion.