A production-shaped HTTP/1.1 server written from scratch in C++, on raw POSIX sockets and Linux epoll. No Boost.Asio, no libuv, no existing HTTP framework the socket lifecycle, the protocol parser, the concurrency model, and the backpressure handling are all hand-written.
- Accepts TCP connections and parses HTTP/1.1 manually — correct partial-read handling (a single
recv()is never assumed to be a complete request), correctContent-Length-aware message boundaries, correct handling of multiple pipelined requests arriving in one read. - Serves static files from a configurable directory with basic MIME-type detection.
- Returns correct status codes for malformed input:
400(missingHostheader, malformed request line),411(missingContent-LengthonPOST),405(unsupported method). - Maintains HTTP/1.1 keep-alive connections through an explicit per-connection state machine (
Idle/Queued/InFlight/Draining), so a connection mid-request or mid-response can never be mistaken for one that's genuinely idle. - Processes pipelined requests in the correct response order, even when they arrive across separate
epollread events and race across different worker threads. - Serves large files correctly under real backpressure (slow/throttled clients) without truncating the transfer or leaking the connection.
- Runs a bounded thread pool (12 workers, 1000-deep queue by default) that sheds load with a clean
503instead of hanging or crashing when saturated. - Degrades gracefully under file-descriptor pressure: a soft connection ceiling throttles new
accept()calls before the OS ever refuses one, with the pressure logged directly at the source. - Uses edge-triggered epoll (
EPOLLET) correctly — reads and writes always drain toEAGAIN, never assuming one event means one complete operation.
- No TLS/HTTPS. Everything is plaintext HTTP.
- No virtual hosting — one
public_dir, one site. - No dynamic routing beyond two hardcoded routes (
/,/about); everything else is static file serving. - No compression (gzip/br).
- No HTTP/2.
- No range requests (no resumable downloads, no video seeking).
The landing page itself — served by this binary at localhost:8080, not a mockup.
Not staged screenshots — the browser's own Network panel, showing the address bar, the actual request, and the response headers returned by the running binary.
Content-Type: text/html, Content-Length: 14, Connection: keep-alive — the exact response transcribed at the top of this README.
Second hardcoded route, served independently: Content-Length: 19, same connection reuse.
Unmatched routes return a real 404 response (Content-Length: 27) — not a browser-generated error page, not a hang.
accept() ──► epoll (edge-triggered, single thread)
│
▼
bounded thread pool (12 workers, 1000-slot queue)
│
▼
per-connection sequence gate (strict FIFO write-back)
│
▼
try_write() ──► Complete / Pending / Failed
A single thread owns the epoll instance and never blocks on I/O. Every connection carries its own std::mutex, and every state transition happens under that lock — there is no global lock on the hot path. An independent sweep thread reaps genuinely idle, queued-too-long, or stalled-draining connections on separate timeouts, so a slow client downloading a large file is never confused with one that's actually gone idle.
| State | Meaning | Timeout |
|---|---|---|
Idle |
Fully processed, nothing in flight | 5s |
Queued |
Bytes received, awaiting a worker thread | 30s |
InFlight |
A worker thread is actively processing this request | never (exempt) |
Draining |
Response is writing but hit backpressure (EAGAIN) |
30s |
Every one of these was reproduced broken before anything was changed, then reproduced fixed after.
A Content-Length header with a non-numeric value (Content-Length: abc) threw an uncaught std::invalid_argument from std::stoul on a worker thread. An uncaught exception on a non-main thread calls std::terminate() — killing the entire process, not just the offending connection.
Fixed by wrapping the parse in a try/catch and validating that the parse consumed the entire value. Verified: the identical malformed request no longer crashes the process.
The original Content-Length lookup was a manual, exact-case string search for "Content-Length: " — one specific capitalization, one specific space. A request sent with content-length: (lowercase) or Content-Length:5 (no space) failed to match, the body length was read as zero, and the un-consumed body bytes corrupted the start of the next pipelined request on the same connection.
Fixed by reusing the existing case-insensitive header-lookup helper instead of a bespoke string search.
A large file to a throttled client hit EAGAIN mid-write (normal backpressure, not a failure) but was marked Idle regardless — indistinguishable from a connection that had genuinely gone idle. The timeout sweep reaped it at the 5-second mark, truncating the download.
Fixed by adding a dedicated Draining state with its own timeout, so the sweep can tell "still actively sending, just slow" apart from "actually idle." Verified live: a 10.5MB file at a 200KB/s throttle now completes byte-for-byte, hash-matched against the untruncated original.
Two requests pipelined on one connection could be picked up by two different worker threads and written back in whichever order finished first — a real RFC 7230 violation, not a cosmetic issue. This happened both within a single read event (multiple requests in one recv()) and across separate read events on the same connection.
Fixed with a per-connection sequence gate: a monotonic counter assigned at extraction time, and a write-side wait that enforces strict FIFO ordering even when the underlying batches genuinely race across threads. Reproduced broken, then reproduced fixed, on both variants of the bug.
Under a constrained file-descriptor limit and a connection flood, accept() began failing with no log signal at all — visible only indirectly, as a cascade of hung-up connections downstream.
Fixed with a soft connection ceiling derived from getrlimit(RLIMIT_NOFILE): the accept loop proactively stops taking new connections and logs the pressure once it nears the real ceiling, instead of running blind until the OS refuses outright.
Benchmarked with wrk across three concurrency tiers to find out which resource actually gives first under load — not just to get one throughput number.
| Concurrency | Req/sec | p50 | p99 | Bottleneck |
|---|---|---|---|---|
| 100 | 33,172 | 2.71ms | 343.00ms | Work-queue contention |
| 1,000 | 20,282 | 47.59ms | 314.62ms | Work-queue contention |
| 10,000 | 23,679 | 136.00ms | 441.14ms | Work-queue contention + graceful 503 shedding |
CPU never exceeded roughly 54% utilization at any tier — the bounded work queue, not the CPU and not (at these levels) file descriptors, is the actual limiting resource. At 10,000 concurrent connections, 1,578 requests timed out and 40,649 received a clean 503 instead of hanging — the bounded queue degrading exactly as designed under genuine overload, rather than falling over.
A separate, deliberately constrained test (server restarted under ulimit -n 200) was used to force and confirm the fd-exhaustion failure mode directly, and to verify the fix: after the soft-ceiling patch, the same load produced clean accept-throttled log events instead of the previous silent cascade, with p99 latency improving from 20.51ms to 11.41ms at identical settings.
Requires CMake and a C++17-capable compiler.
git clone https://github.com/jj761/http-server-cpp.git
cd http-server-cpp
mkdir build && cd build
cmake ..
makecd build
./http_serverListens on port 8080 by default. Static files are served from public/ at the project root — drop any HTML/CSS/JS/images there and they're reachable at the matching path.
curl http://localhost:8080/A few things worth trying against a running instance:
# Malformed request handling
curl -s -X FOOBAR -o /dev/null -w "http_code=%{http_code}\n" http://localhost:8080/
# expect 405
# Keep-alive + large file under throttle
curl --limit-rate 200k -o /tmp/test.bin http://localhost:8080/large_test.bin
# Connection flood
seq 1000 | xargs -P 200 -I{} curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/ | sort | uniq -csrc/
main.cpp entry point, thread pool + socket setup
server.cpp epoll event loop, accept handling, timeout sweep
connection.cpp per-connection state, backpressured writes, close funnel
http_parsing.cpp request-line parsing, header lookup, message extraction
router.cpp routing, malformed-request handling, static file serving
thread_pool.cpp bounded worker pool with condition-variable dispatch
include/
*.hpp corresponding headers
public/
index.html this project's own portfolio page, served by itself
docs/
preview-*.png screenshots of the landing page (hero, architecture, bugs, benchmarks, scope)
verify-*.png Network-panel screenshots proving the live server serves each route







