Build a Minimal C++ HTTP Framework from Scratch with Asio
Why Build Your Own HTTP Server?
Production HTTP in C++ means Boost.Beast, Crow, or Drogon. So why build one from scratch?
Because understanding the internals makes you better at using the production libraries. Once you have hand-written an HTTP parser, a routing table, and a middleware chain, reading Beast source code stops being mysterious — you recognize every part.
There are also real use cases for a minimal custom server:
- Embedded targets — Beast is large; a minimal parser + router can fit in tens of kilobytes
- Mixed protocols — HTTP on the same port as a custom TCP protocol; owning the parser pipeline makes dispatch easier
- Interview prep — “how does routing work?” is a common senior C++ interview question
This article builds a complete, working minimal HTTP server: async accept, HTTP/1.1 request parsing, method+path routing, and a middleware chain. All in under 400 lines of C++.
Architecture Overview
The data flow is:
Client → Acceptor → Session (async_read) → Parser → Router → Middleware chain → Handler → async_write → Client
Each component has a single responsibility:
| Component | Role |
|---|---|
Acceptor | Listens on a port, spawns a Session for each connection |
Session | Reads bytes until \r\n\r\n, then optional body by Content-Length |
Parser | Converts raw bytes into Request (method, path, headers, body) |
Router | Maps (method, path) to a handler function |
Middleware | Wraps handlers: logging, auth, CORS headers |
Handler | Business logic, returns a Response |
| Serializer | Converts Response to HTTP bytes for async_write |
Request and Response Types
#include <string>
#include <unordered_map>
#include <functional>
struct Request {
std::string method; // "GET", "POST", ...
std::string path; // "/api/items"
std::string version; // "HTTP/1.1"
std::unordered_map<std::string, std::string> headers;
std::string body;
};
struct Response {
int status = 200;
std::string status_text = "OK";
std::unordered_map<std::string, std::string> headers;
std::string body;
Response() {
headers["Content-Type"] = "text/plain";
}
// Factory helpers
static Response ok(std::string body) {
Response r;
r.body = std::move(body);
r.headers["Content-Length"] = std::to_string(r.body.size());
return r;
}
static Response json(std::string body) {
Response r;
r.body = std::move(body);
r.headers["Content-Type"] = "application/json";
r.headers["Content-Length"] = std::to_string(r.body.size());
return r;
}
static Response error(int code, std::string msg) {
Response r;
r.status = code;
r.status_text = std::move(msg);
r.body = r.status_text;
r.headers["Content-Length"] = std::to_string(r.body.size());
return r;
}
// Serialize to HTTP/1.1 wire format
std::string serialize() const {
std::string out;
out += "HTTP/1.1 " + std::to_string(status) + " " + status_text + "\r\n";
for (const auto& [k, v] : headers)
out += k + ": " + v + "\r\n";
out += "\r\n";
out += body;
return out;
}
};
HTTP Parser
Parsing HTTP/1.1 is line-by-line until the blank line separating headers from body:
#include <sstream>
#include <algorithm>
// Remove trailing \r from lines produced by getline on \r\n input
static std::string stripCR(std::string s) {
if (!s.empty() && s.back() == '\r') s.pop_back();
return s;
}
// Returns false if the request is malformed
bool parseRequest(const std::string& raw, Request& req) {
std::istringstream stream(raw);
std::string line;
// Request line: "GET /path HTTP/1.1"
if (!std::getline(stream, line)) return false;
line = stripCR(line);
std::istringstream req_line(line);
if (!(req_line >> req.method >> req.path >> req.version))
return false;
// Headers: "Key: Value" until blank line
while (std::getline(stream, line)) {
line = stripCR(line);
if (line.empty()) break; // end of headers
auto colon = line.find(':');
if (colon == std::string::npos) return false;
std::string key = line.substr(0, colon);
std::string val = line.substr(colon + 1);
// Trim leading whitespace from value
val.erase(0, val.find_first_not_of(' '));
// Normalize header key to lowercase
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
req.headers[key] = val;
}
// Body — read Content-Length bytes
auto it = req.headers.find("content-length");
if (it != req.headers.end()) {
size_t len = std::stoul(it->second);
req.body.resize(len);
stream.read(req.body.data(), static_cast<std::streamsize>(len));
}
return true;
}
Router
A simple map-based router. The key is method + " " + path:
using Handler = std::function<Response(const Request&)>;
using Next = std::function<Response(const Request&)>;
using Middleware = std::function<Response(const Request&, Next)>;
class Router {
std::unordered_map<std::string, Handler> routes_;
std::vector<Middleware> middleware_;
std::string makeKey(const std::string& method, const std::string& path) {
return method + " " + path;
}
public:
void get(const std::string& path, Handler h) {
routes_[makeKey("GET", path)] = std::move(h);
}
void post(const std::string& path, Handler h) {
routes_[makeKey("POST", path)] = std::move(h);
}
void use(Middleware mw) {
middleware_.push_back(std::move(mw));
}
Response dispatch(const Request& req) const {
auto it = routes_.find(req.method + " " + req.path);
if (it == routes_.end())
return Response::error(404, "Not Found");
Handler handler = it->second;
// Build middleware chain (right-to-left)
// The innermost Next calls the actual handler
Next chain = [&handler](const Request& r) {
return handler(r);
};
for (int i = static_cast<int>(middleware_.size()) - 1; i >= 0; --i) {
Middleware mw = middleware_[i];
Next outer_chain = chain;
chain = [mw, outer_chain](const Request& r) {
return mw(r, outer_chain);
};
}
return chain(req);
}
};
Middleware Examples
// Logging middleware
Middleware logger = [](const Request& req, Next next) {
auto start = std::chrono::steady_clock::now();
auto resp = next(req);
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
std::printf("[%s] %s %s → %d (%ldms)\n",
req.headers.count("host") ? req.headers.at("host").c_str() : "-",
req.method.c_str(), req.path.c_str(), resp.status, ms);
return resp;
};
// Auth middleware (bearer token check)
Middleware auth = [](const Request& req, Next next) {
auto it = req.headers.find("authorization");
if (it == req.headers.end() || it->second != "Bearer secret-token")
return Response::error(401, "Unauthorized");
return next(req);
};
// CORS headers middleware
Middleware cors = [](const Request& req, Next next) {
auto resp = next(req);
resp.headers["Access-Control-Allow-Origin"] = "*";
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
return resp;
};
Async Session (Boost.Asio)
The session reads the HTTP request asynchronously, dispatches to the router, and writes the response:
#include <boost/asio.hpp>
#include <memory>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
tcp::socket socket_;
asio::streambuf buffer_;
const Router& router_;
public:
Session(tcp::socket socket, const Router& router)
: socket_(std::move(socket)), router_(router) {}
void start() { readHeaders(); }
private:
void readHeaders() {
auto self = shared_from_this();
// Read until the blank line separating headers from body
asio::async_read_until(socket_, buffer_, "\r\n\r\n",
[self](boost::system::error_code ec, std::size_t bytes_transferred) {
if (ec) return; // connection closed or error
// Extract the header section
std::string raw{
asio::buffers_begin(self->buffer_.data()),
asio::buffers_begin(self->buffer_.data()) + bytes_transferred
};
self->buffer_.consume(bytes_transferred);
Request req;
if (!parseRequest(raw, req)) {
self->sendResponse(Response::error(400, "Bad Request"));
return;
}
// If there is a body, read it
auto it = req.headers.find("content-length");
if (it != req.headers.end()) {
size_t body_len = std::stoul(it->second);
if (body_len > 0) {
self->readBody(std::move(req), body_len);
return;
}
}
self->sendResponse(self->router_.dispatch(req));
});
}
void readBody(Request req, std::size_t len) {
auto self = shared_from_this();
asio::async_read(socket_, buffer_, asio::transfer_exactly(len),
[self, req = std::move(req)](boost::system::error_code ec, std::size_t n) mutable {
if (ec) return;
req.body = std::string{
asio::buffers_begin(self->buffer_.data()),
asio::buffers_begin(self->buffer_.data()) + n
};
self->buffer_.consume(n);
self->sendResponse(self->router_.dispatch(req));
});
}
void sendResponse(Response resp) {
auto self = shared_from_this();
auto data = std::make_shared<std::string>(resp.serialize());
asio::async_write(socket_, asio::buffer(*data),
[self, data](boost::system::error_code /*ec*/, std::size_t /*n*/) {
// Connection closed after response (HTTP/1.0 style)
// For keep-alive, call self->readHeaders() here instead
});
}
};
Acceptor
class HttpServer {
asio::io_context& io_;
tcp::acceptor acceptor_;
Router router_;
public:
HttpServer(asio::io_context& io, unsigned short port)
: io_(io)
, acceptor_(io, tcp::endpoint(tcp::v4(), port))
{}
Router& router() { return router_; }
void run() {
acceptOne();
io_.run();
}
private:
void acceptOne() {
acceptor_.async_accept(
[this](boost::system::error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<Session>(std::move(socket), router_)->start();
}
acceptOne(); // accept next connection
});
}
};
Putting It Together
#include <iostream>
#include <csignal>
asio::io_context io;
void signalHandler(int) { io.stop(); }
int main() {
std::signal(SIGINT, signalHandler);
HttpServer server(io, 8080);
// Register middleware
server.router().use(logger);
server.router().use(cors);
// Register routes
server.router().get("/", [](const Request&) {
return Response::ok("Hello from C++ HTTP server!\n");
});
server.router().get("/health", [](const Request&) {
return Response::json(R"({"status":"ok"})");
});
server.router().post("/echo", [](const Request& req) {
return Response::json(req.body);
});
server.router().get("/protected", [](const Request& req) {
return Response::ok("Secret data\n");
});
// Apply auth only to /protected by wrapping the handler:
// Or apply auth middleware globally before other routes
std::cout << "Listening on port 8080\n";
server.run();
}
Test with curl:
curl http://localhost:8080/
# Hello from C++ HTTP server!
curl http://localhost:8080/health
# {"status":"ok"}
curl -X POST http://localhost:8080/echo -d '{"key":"value"}' -H "Content-Type: application/json"
# {"key":"value"}
Common Failures
| Issue | Cause | Fix |
|---|---|---|
| Connection reset before response | socket_ destroyed before async_write completes | Use shared_from_this() to keep session alive |
\r left on header values | getline on \r\n streams leaves \r | stripCR() after every getline |
| Body not read (Content-Length present) | Only reading up to \r\n\r\n | After headers, do a second async_read(transfer_exactly(len)) |
| EOF treated as error | Connection closed normally | if (ec == asio::error::eof) return; |
| Huge body accepted | No size cap → DoS risk | Check Content-Length before reading; return 413 if too large |
buffer_.consume not called | Next request reads stale data | Always consume processed bytes |
When to Use Boost.Beast Instead
| Situation | Choice |
|---|---|
| Production service, correctness matters | Beast — standards-complete HTTP/1.1 and HTTP/2, full chunked encoding, WebSocket |
| Quick REST API prototype | Crow or Drogon — header-only, Express-like API |
| Embedded target, <50KB budget | Custom minimal parser (this article) |
| Learning how HTTP parsing works | Custom minimal parser (this article) |
Library comparison:
├── Boost.Beast → production HTTP/WebSocket on Asio; comprehensive
├── Crow → header-only, Express API, fast to prototype
├── Drogon → full async stack, C++17, ORM included
└── Custom minimal → learning, embedded, custom protocols
Key Takeaways
- Parser: read until
\r\n\r\n, then read body by Content-Length — two async reads, not one stripCRafter everygetline—\r\nline endings leave a\rthat breaks header parsing- Router: a
std::unordered_map<string, Handler>keyed by"METHOD /path"covers 90% of routing needs - Middleware chain: build right-to-left by wrapping
Nextfunctions — each middleware calls the inner chain shared_from_thisin async lambdas — keeps the session alive untilasync_writecompletes- Body size cap: always check Content-Length before reading; reject oversized requests with 413
- Use Beast for production — this custom implementation teaches the concepts; Beast handles edge cases, chunked encoding, keep-alive, and HTTP/2
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. HTTP parsing, routing, middleware chains, and async I/O with Boost.Asio.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]
- C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]
- C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]
Keywords Covered in This Article (Related Search Terms)
This article covers C++, HTTP, web framework, Asio, routing, middleware, Boost.