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:

ComponentRole
AcceptorListens on a port, spawns a Session for each connection
SessionReads bytes until \r\n\r\n, then optional body by Content-Length
ParserConverts raw bytes into Request (method, path, headers, body)
RouterMaps (method, path) to a handler function
MiddlewareWraps handlers: logging, auth, CORS headers
HandlerBusiness logic, returns a Response
SerializerConverts 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;
    }
};

Content-Length is not optional decoration. Without it (and without chunked encoding), an HTTP/1.1 client has no way to know where the body ends except by waiting for the connection to close. The factory helpers set it so every response is self-delimiting; a handler that builds a Response by hand and forgets the header works with curl (because this server closes the connection after writing) but breaks the moment you add keep-alive. Note that Response::error puts the caller’s message into the status line: that is fine for fixed strings like "Not Found", but a message containing \r\n from user input would let an attacker inject headers, so never pass untrusted text there.

Using std::unordered_map for headers keeps the example short but loses two properties of real HTTP: header order is not preserved, and a header may legitimately appear more than once (Set-Cookie in responses is the classic case), which a map cannot represent.


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;
}

In this server the parser only ever receives the header section (the session reads the body separately below), so the body branch at the end is a no-op there; it is kept so the function also works on a complete request captured in a test. That split is the core difficulty of HTTP parsing over TCP: the network delivers bytes, not messages, so one read can return half a request line, or the headers plus part of the body, or even the start of the next request.

Several shortcuts here are fine for learning but matter in anything exposed to the internet:

  • std::stoul throws on a non-numeric Content-Length (std::invalid_argument) or an absurdly large one (std::out_of_range). Thrown inside an Asio completion handler, that exception propagates out of io_context::run() and terminates the whole server — one malformed request becomes a denial of service. Wrap the conversion, or use std::from_chars, and answer 400.
  • Duplicate or conflicting length headers. A request with two different Content-Length values, or Content-Length together with Transfer-Encoding: chunked, must be rejected; accepting them inconsistently between a proxy and a backend is the basis of HTTP request smuggling attacks.
  • The path includes the query string. GET /health?verbose=1 arrives with req.path == "/health?verbose=1", which the exact-match router below will not find. Split at ? into path and query before routing, and percent-decode the path.
  • ::tolower on char is undefined behavior for negative values (non-ASCII bytes on platforms where char is signed); cast to unsigned char first.

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);
    }
};

The chain is built from the inside out. Starting with a Next that calls the handler, each iteration wraps the current chain in a new function that calls middleware i with the chain built so far as its next. Because the loop runs from the last middleware to the first, the first middleware registered with use() ends up outermost: it runs first on the way in and last on the way out. That ordering is what you want — a logger registered first measures the time of everything after it, including auth — and it matches how Express and most frameworks behave.

The cost is that the chain is rebuilt on every request, with one std::function allocation per middleware layer. For a handful of middleware that is small next to socket I/O, but it is an easy optimization target: since routes and middleware do not change after startup, the wrapped chain for each route can be built once at registration time. The [&handler] capture is safe only because chain is called before dispatch returns; storing that chain for later use would leave it pointing at a destroyed local. Also note that middleware runs only for matched routes here — a 404 bypasses the logger and CORS headers, which is usually not what you want for logging; frameworks typically run global middleware before routing.

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 (%lldms)\n",
        req.headers.count("host") ? req.headers.at("host").c_str() : "-",
        req.method.c_str(), req.path.c_str(), resp.status,
        static_cast<long long>(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;
};

(ms is cast because milliseconds::rep is a 64-bit integer whose exact type differs between platforms — long on Linux, long long on Windows — and a mismatched printf format is undefined behavior.)

Each middleware shows one of the three things a middleware can do: act around the handler (logging), short-circuit without calling next (auth returns 401 and the handler never runs), or modify the response on the way out (CORS). The auth example compares the token with !=, which is fine for a demo but leaks timing information for real secrets; use a constant-time comparison. The CORS middleware only decorates responses from matched routes, and browsers send an OPTIONS preflight request before cross-origin POSTs with JSON bodies — since no route handles OPTIONS /echo, the preflight gets a 404 without CORS headers and the browser blocks the real request. A working CORS layer has to answer OPTIONS itself, before routing.


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();

        // async_read_until may already have pulled part (or all) of the body
        // into buffer_; only read what is still missing
        std::size_t have = buffer_.size();
        std::size_t need = len > have ? len - have : 0;

        asio::async_read(socket_, buffer_, asio::transfer_exactly(need),
            [self, len, req = std::move(req)](boost::system::error_code ec, std::size_t) mutable {
                if (ec) return;

                req.body = std::string{
                    asio::buffers_begin(self->buffer_.data()),
                    asio::buffers_begin(self->buffer_.data()) + len
                };
                self->buffer_.consume(len);

                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
            });
    }
};

The self = shared_from_this() capture in every handler is what keeps the Session alive between asynchronous steps. Nothing else owns it: the acceptor creates it with make_shared, calls start(), and drops its pointer. Each pending operation’s completion handler holds a shared_ptr, so the session lives exactly as long as some operation is in flight; when the last handler finishes without starting another operation, the count drops to zero and the destructor closes the socket. Capturing a raw this instead compiles and works in light testing, then crashes under load when a session is destroyed while a read is still pending — the most common bug in hand-written Asio servers. The same reasoning explains the std::make_shared<std::string> in sendResponse: async_write does not copy the buffer, so the bytes must outlive the call, and capturing data in the handler guarantees that.

The body read fixes a subtle problem. async_read_until reads in chunks and usually reads past the delimiter, so when a small POST arrives in one TCP segment, buffer_ already holds the whole body by the time the header handler runs. Reading transfer_exactly(len) more bytes would then wait for data the client never sends, and the request hangs until the client times out — exactly the “POST body empty or request hangs” symptom described in the FAQ. The version above reads only the missing bytes. The same leftover-bytes rule is what makes keep-alive work: after a response, anything still in buffer_ is the beginning of the next request.

Two things are still missing for production use: a timeout (a client that opens a connection and never sends \r\n\r\n holds a session forever — the slowloris attack; use an asio::steady_timer that closes the socket), and a limit on header size (async_read_until keeps growing buffer_ until it finds the delimiter; construct the streambuf with a maximum size so oversized headers fail with an error instead of exhausting memory).


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
            });
    }
};

The acceptor re-arms itself in its own completion handler, so exactly one async_accept is pending at a time; this is the standard Asio accept loop. Capturing this is safe here, unlike in Session, because the HttpServer outlives io_.run(). With a single thread calling io_.run(), all handlers run on that thread, so the shared Router needs no locking — which is also why handlers must not block: a handler that sleeps or runs a slow synchronous database query stalls every other connection. To use multiple cores, call io_.run() from several threads; the router is only read after startup, so that stays safe, but any shared mutable state in handlers then needs synchronization. If bind fails because the port is in use, the tcp::acceptor constructor throws boost::system::system_error (“Address already in use”); setting reuse_address(true) avoids spurious failures when restarting the server while old connections are in TIME_WAIT.


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"}

std::signal with a handler that calls io.stop() is the shortest way to make Ctrl+C work, but calling into Asio from a signal handler is not async-signal-safe. Asio’s own asio::signal_set delivers the signal as a normal completion handler on the io_context, where calling acceptor_.close() or io.stop() is safe; prefer it in real code. The /protected route shows a gap in this design: middleware registered with use() applies to every route, so per-route auth needs either a wrapper (router.get("/protected", withAuth(handler)), where withAuth returns a handler that checks the token and then calls the original) or route groups, which is one of the first features real frameworks add.


Common Failures

IssueCauseFix
Connection reset before responsesocket_ destroyed before async_write completesUse shared_from_this() to keep session alive
\r left on header valuesgetline on \r\n streams leaves \rstripCR() after every getline
Body not read (Content-Length present)Only reading up to \r\n\r\nAfter headers, do a second async_read(transfer_exactly(len))
EOF treated as errorConnection closed normallyif (ec == asio::error::eof) return;
Huge body acceptedNo size cap → DoS riskCheck Content-Length before reading; return 413 if too large
buffer_.consume not calledNext request reads stale dataAlways consume processed bytes

When to Use Boost.Beast Instead

SituationChoice
Production service, correctness mattersBeast — complete HTTP/1.1 (chunked encoding, keep-alive, pipelining) and WebSocket; no HTTP/2 — put a proxy such as nginx or Envoy in front if you need it
Quick REST API prototypeCrow or Drogon — header-only, Express-like API
Embedded target, <50KB budgetCustom minimal parser (this article)
Learning how HTTP parsing worksCustom 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

What the hand-written server teaches, and when to switch to Beast

  • Parser: read until \r\n\r\n, then read body by Content-Length — two async reads, not one
  • stripCR after every getline — \r\n line endings leave a \r that 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 Next functions — each middleware calls the inner chain
  • shared_from_this in async lambdas — keeps the session alive until async_write completes
  • 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 WebSocket (HTTP/2 needs a different library or a proxy)

Frequently Asked Questions (FAQ)

Q. Why is the POST body empty even though the client sends Content-Length?

A. Reading until \r\n\r\n only gives you the headers; the body arrives after that and may not be in the buffer yet. After parsing headers, subtract what is already buffered and do a second async_read with transfer_exactly for the remaining bytes, as readBody does above. Check the Content-Length value against a size cap before reading, and return 413 if it is too large, otherwise one request can make the server allocate unbounded memory.