A non-blocking, epoll-based HTTP/1.1 server written in C++17 with no external frameworks.
- Architecture
- Components
- Data Flow
- Project Structure
- Building
- Testing
- Configuration
- Adding Routes
- Design Decisions
- Known Limits
main.cpp
└── TcpServer
└── EventLoop (epoll, edge-triggered)
│
▼
Connection (one per socket fd)
│
├── ReadBuffer ──► HttpParser ──► HttpRequest
│ │
│ ▼
│ Router ──► Handler
│ │
└── WriteBuffer ◄────────────── HttpResponse ◄────────┘
All I/O runs on a single event-loop thread using edge-triggered epoll. Handlers must not block; the event loop stalls if they do.
| Component | File | Responsibility |
|---|---|---|
EventLoop |
event_loop.hpp/cpp |
Owns the epoll fd. Registers/removes fd interests. Drives run(). Sweeps idle timers. |
TcpServer |
tcp_server.hpp/cpp |
Creates the listen socket. Accepts connections in a loop (ET). Tracks live connections. |
Connection |
connection.hpp/cpp |
Owns one socket fd (RAII). Manages read/write buffers and the per-connection state machine. Handles keep-alive, idle timeout, and pipelining. |
HttpParser |
http_parser.hpp/cpp |
Incremental, stateful HTTP/1.1 parser. Feeds on partial reads. Returns Complete, Incomplete, or Error. |
HttpRequest |
http_request.hpp/cpp |
Plain value type: method, path, query string, headers, body, route params. |
HttpResponse |
http_response.hpp/cpp |
Builder pattern. Serialises to a wire-format string. |
Router |
router.hpp/cpp |
Trie-based. Supports literal and parameterised segments (/:id). Returns 404 / 405 automatically. |
Logger |
logger.hpp/cpp |
Structured JSON lines to stdout. One line per event. Configurable minimum level. |
types.hpp |
— | Result<T,E>, Error, HttpMethod, StatusCode. No external dependencies. |
1. kernel → epoll_wait → EventLoop::dispatch
2. → TcpServer::on_accept_ready
3. accept4() loop until EAGAIN
4. Connection::create() per fd
5.
6. kernel → EPOLLIN → Connection::on_io_event
7. → Connection::drain_read_buffer
8. recv() loop until EAGAIN (ET requirement)
9. if response pending → accumulate in read_backlog_
10. else → Connection::process_incoming_bytes
11. HttpParser::feed(chunk)
12. loop while ParseStatus::Complete:
13. parser_.take_unconsumed() (pipelining)
14. Connection::handle_request
15. Router::dispatch(request) → handler → HttpResponse
16. enqueue_response(response.serialize())
17. flush_write_buffer()
18. send() loop until EAGAIN or queue empty
19. if partial → arm EPOLLOUT, set response_pending_
20.
21. kernel → EPOLLOUT → Connection::on_io_event
22. → flush_write_buffer() drains remaining queue
23. on full drain: clear response_pending_
24. disable EPOLLOUT
25. process read_backlog_ if any
Http/
├── Dockerfile # Multi-stage build (gcc:13 builder → debian:slim runtime)
├── compose.yaml # Podman / Docker Compose
├── CMakeLists.txt # CMake build definition (C++17, warnings-as-errors)
├── README.md
└── src/
├── types.hpp/cpp # Result<T,E>, Error, enums
├── logger.hpp/cpp # Structured JSON logger
├── http_request.hpp/cpp # Parsed request value type
├── http_response.hpp/cpp# Builder + wire serialiser
├── http_parser.hpp/cpp # Incremental HTTP/1.1 parser
├── router.hpp/cpp # Trie router with :param support
├── event_loop.hpp/cpp # epoll abstraction + timer sweep
├── connection.hpp/cpp # Per-connection state machine
├── tcp_server.hpp/cpp # Listen socket + accept loop
└── main.cpp # Entry point, route registration
Requires Podman and podman-compose.
# Start the machine (macOS only)
podman machine start
# Build image and start server
podman compose up --build
# Run in background
podman compose up --build -d
# Stop
podman compose downServer listens on http://localhost:8080.
Requires GCC 11+, CMake 3.16+.
# Release build
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j$(nproc)
./http_server 8080
# Debug build (AddressSanitizer + UBSan enabled)
cmake .. -DCMAKE_BUILD_TYPE=Debug
make -j$(nproc)
./http_server 8080# Health check
curl http://localhost:8080/health
# Parameterised route
curl http://localhost:8080/users/42
# POST with body
curl -X POST http://localhost:8080/echo \
-H "Content-Type: application/json" \
-d '{"hello":"world"}'
# 404 — no matching route
curl http://localhost:8080/nonexistent
# 405 — wrong method
curl -X POST http://localhost:8080/healthExpected responses:
| Request | Status | Body |
|---|---|---|
GET /health |
200 | {"status":"ok"} |
GET /users/42 |
200 | {"id":"42","name":"example user"} |
POST /echo |
200 | request body echoed |
GET /nonexistent |
404 | 404 Not Found |
POST /health |
405 | 405 Method Not Allowed |
Log output is one JSON line per event:
{"ts":"2026-04-04T14:48:48.662Z","level":"info","event":"server_started","address":"0.0.0.0","port":"8080"}
{"ts":"2026-04-04T14:48:49.665Z","level":"info","event":"request","method":"GET","path":"/health"}Edit ServerConfig in main.cpp:
ServerConfig config;
config.bind_address = "0.0.0.0"; // interface to bind
config.port = 8080; // TCP port
config.backlog = 128; // listen() backlog
config.idle_timeout = std::chrono::seconds{30}; // per-connection idle timeoutPort can also be overridden at runtime:
./http_server 9090Log level (default INFO):
Logger::set_level(LogLevel::DEBUG); // DEBUG | INFO | WARN | ERRORAll routes are registered in register_routes() in main.cpp.
// Exact path
router.add_route(HttpMethod::GET, "/ping", [](const HttpRequest&) {
return HttpResponse::builder(StatusCode::OK)
.body("pong")
.build();
});
// Parameterised segment — value available in request.params["name"]
router.add_route(HttpMethod::GET, "/users/:id/posts/:post_id",
[](const HttpRequest& req) {
auto user_id = req.params.at("id");
auto post_id = req.params.at("post_id");
// ...
return HttpResponse::builder(StatusCode::OK)
.body("{}", "application/json")
.build();
});
// POST with body access
router.add_route(HttpMethod::POST, "/items",
[](const HttpRequest& req) {
// req.body contains the raw request body
// req.header("content-type") for MIME type
return HttpResponse::builder(StatusCode::Created)
.body(req.body, "application/json")
.build();
});Handler type: std::function<HttpResponse(const HttpRequest&)>
Handlers must be non-blocking. Any blocking call (file I/O, database, sleep) stalls every other connection.
| Decision | Rationale |
|---|---|
| Edge-triggered epoll | Fewer wake-ups; requires recv()/send() until EAGAIN on every event |
| Single event-loop thread | No locking needed; simple mental model; handlers must be non-blocking |
Result<T,E> instead of exceptions |
Explicit error propagation; no hidden control flow in hot paths |
take_unconsumed() on parser |
Correct HTTP/1.1 pipelining — bytes after request N are fed into parser for request N+1 |
response_pending_ flag |
Prevents processing request N+1 before response N is fully written; backpressure via read_backlog_ |
pair<time_point, id> timer key |
Eliminates timer collision when two connections share an identical expiry timestamp |
TCP_NODELAY |
Reduces latency for small responses by disabling Nagle's algorithm |
| Multi-stage Dockerfile | Builder image (~1.5 GB) discarded; runtime image is debian:bookworm-slim (~80 MB) |
| Limit | Detail |
|---|---|
| Single-threaded | One slow handler blocks all connections |
| No TLS | Plain TCP only; put nginx/Caddy in front for HTTPS |
| No chunked request bodies | Only Content-Length-delimited bodies |
No sendfile |
Responses are copied through userspace; not ideal for large files |
| ~10k connections | Practical ceiling before single-thread CPU becomes the bottleneck; scale with SO_REUSEPORT + multiple processes |