Parsing HTTP Correctly in C++: Headers, Chunked Encoding and a Boost.Beast Parser

Introduction: “HTTP request parsing is full of bugs”

Problem Scenario 1: Manual Parsing Pitfalls

// ❌ Problem: manual parsing crashes on edge cases
std::string parse_path(const std::string& raw) {
    auto pos = raw.find(" ");
    auto pos2 = raw.find(" ", pos + 1);
    return raw.substr(pos + 1, pos2 - pos - 1);  // 💥 Multiple spaces? Empty string?
}
// Issues:
// - HTTP/1.0 vs HTTP/1.1 differences
// - Consecutive spaces, tabs, CRLF vs LF mixing
// - Multi-byte characters (Content-Length vs actual bytes)
// - Chunked encoding: Transfer-Encoding: chunked handling missing

Why does this happen? HTTP protocol looks simple but has many edge cases: \r\n vs \n, consecutive spaces, percent encoding, chunked encoding, Keep-Alive, etc. Manual parsing accumulates bugs. The deeper problem is that HTTP/1.1 is a text protocol with binary framing rules. The start line and headers are text, but where the message ends is decided by a small set of precedence rules (no body for HEAD and 1xx/204/304, then Transfer-Encoding, then Content-Length, then connection close). A parser that gets any of those rules slightly wrong doesn’t just fail on odd input: on a keep-alive connection it reads part of one message as the start of the next, and every following request on that connection is garbage.

That framing ambiguity is also a security issue. When a proxy and a backend disagree about where a request ends — for example one honors Content-Length and the other Transfer-Encoding: chunked, or one accepts a bare \n line ending and the other doesn’t — an attacker can hide a second request inside the body of the first. That is HTTP request smuggling, and most real-world instances come from exactly the kind of lenient, hand-written parsing shown above.

Additional Problem Scenarios

Scenario 2: Content-Length and body mismatch
Client sends Content-Length: 100 but only 50 bytes arrive—async_read waits forever without timeout, blocking server threads.

Scenario 3: Chunked encoding parsing failure
Streaming responses with Transfer-Encoding: chunked not handled—body truncated. Essential for large file downloads and real-time streaming.

Scenario 4: Header case and duplicates
Content-Type vs content-type, multiple Set-Cookie headers not handled—parsing errors or security vulnerabilities.

Scenario 5: Request split across TCP packets
One request arrives over multiple async_read_some calls. Failure to detect “request complete” passes wrong data to next request.

Solution:

  1. Boost.Beast: RFC-compliant parser, built-in error handling
  2. flat_buffer: preserves data during parsing
  3. http::read: automatically determines request/response completion
  4. chunked encoding: Beast handles automatically This article walks through the structure of HTTP requests and responses, header parsing (case-insensitivity, duplicates, encoding), chunked transfer encoding, and how Beast’s parser handles them, followed by common errors and patterns for server and client code.

Requirements: Boost.Beast 1.70+, Boost.Asio 1.70+ (beast::tcp_stream with built-in timeouts arrived in 1.70)

The hand-written parsers in sections 2–5 are there to show what has to be handled, not as code to deploy. Each one is followed by what it gets wrong, which is the most useful way to understand why Beast’s parser is shaped the way it is.


HTTP Protocol Structure

Request/Response Flow

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: Request Line + Headers + CRLF + Body
    Note over S: Parse → Route → Process
    S->>C: Status Line + Headers + CRLF + Body

HTTP Request Structure

GET /api/users?id=1 HTTP/1.1\r\n
Host: example.com\r\n
Content-Type: application/json\r\n
Content-Length: 0\r\n
\r\n

Components:

  • Request Line: METHOD SP Request-URI SP HTTP-Version CRLF
  • Headers: Field-Name: Field-Value CRLF (repeated)
  • Blank line: CRLF (separates headers from body)
  • Body: length determined by Content-Length or Transfer-Encoding: chunked

HTTP Response Structure

HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 27\r\n
\r\n
{"message":"Hello World"}

Components:

  • Status Line: HTTP-Version SP Status-Code SP Reason-Phrase CRLF
  • Headers: same format as request
  • Blank line: separates headers from body
  • Body: response content

HTTP Message Parsing Visualization

flowchart TB
    subgraph Request[HTTP Request]
        RL[Request Line\nGET /path HTTP/1.1]
        H1[Headers\nHost: example.com\nContent-Type: ...]
        BL[Blank Line CRLF]
        BD[Body\nContent Data]
    end
    RL --> H1 --> BL --> BD
    style RL fill:#4caf50
    style BL fill:#ff9800

Request Parsing

Request Line Parsing

#include <string>
#include <sstream>
#include <stdexcept>
struct ParsedRequestLine {
    std::string method;   // GET, POST, ...
    std::string path;     // /api/users
    std::string query;    // id=1 (query string)
    std::string version;  // HTTP/1.1
};
ParsedRequestLine parse_request_line(const std::string& line) {
    std::istringstream iss(line);
    ParsedRequestLine result;
    // METHOD SP Request-URI SP HTTP-Version
    if (!(iss >> result.method >> result.path >> result.version)) {
        throw std::runtime_error("Invalid request line");
    }
    // Split query string: /api/users?id=1 → path=/api/users, query=id=1
    auto qpos = result.path.find('?');
    if (qpos != std::string::npos) {
        result.query = result.path.substr(qpos + 1);
        result.path = result.path.substr(0, qpos);
    }
    return result;
}
// Usage
int main() {
    auto parsed = parse_request_line("GET /api/users?id=1 HTTP/1.1");
    // parsed.method == "GET"
    // parsed.path == "/api/users"
    // parsed.query == "id=1"
    // parsed.version == "HTTP/1.1"
}

Note: Production requires percent decoding (%20 → space) and path traversal attack prevention (/../../../etc/passwd). Beast does not do either: req.target() returns the raw request-target exactly as the client sent it. Beast’s own file-server example rejects any target containing .. and builds the filesystem path itself. Decoding has an ordering trap: if you check for .. before percent-decoding, /%2e%2e/etc/passwd gets through, so decode first, then normalize, then check that the result is still under your document root.

std::istringstream >> is also more lenient than the grammar. It accepts tabs and runs of spaces between tokens, which RFC 9112 does not allow in the request line; being lenient here is how a server ends up interpreting a request differently from the proxy in front of it.

Headers and Body Separation

// CRLF twice = end of headers
std::pair<std::string, std::string> split_headers_and_body(
    const std::string& raw)
{
    // Find \r\n\r\n or \n\n (some clients use LF only)
    const std::string crlfcrlf = "\r\n\r\n";
    const std::string lflf = "\n\n";
    auto pos = raw.find(crlfcrlf);
    if (pos == std::string::npos) {
        pos = raw.find(lflf);
    }
    if (pos == std::string::npos) {
        return {"", ""};  // Still receiving headers
    }
    size_t header_end = (raw.find(crlfcrlf) != std::string::npos)
        ? pos + crlfcrlf.size()
        : pos + lflf.size();
    return {
        raw.substr(0, pos),
        raw.substr(header_end)
    };
}

Content-Length Based Body Reading

#include <cstdlib>
#include <optional>
std::optional<size_t> get_content_length(const std::string& headers) {
    // Extract 123 from Content-Length: 123
    const std::string key = "Content-Length:";
    auto pos = headers.find(key);
    if (pos == std::string::npos) {
        return std::nullopt;  // No body or chunked
    }
    pos += key.size();
    while (pos < headers.size() && headers[pos] == ' ') ++pos;
    char* end;
    long value = std::strtol(headers.c_str() + pos, &end, 10);
    if (value < 0 || end == headers.c_str() + pos) {
        return std::nullopt;  // Invalid format
    }
    return static_cast<size_t>(value);
}

Both helpers illustrate subtle problems. split_headers_and_body accepts a bare \n\n terminator for tolerance, but if a proxy in front of you only recognizes \r\n\r\n, the two now disagree about where the headers end. RFC 9112 lets a recipient accept bare LF, but a server behind a proxy is safer rejecting it.

get_content_length has three bugs that are typical of string-searching parsers. find("Content-Length:") is case-sensitive, so content-length: 10 (which HTTP/2-to-1.1 gateways commonly emit in lowercase) is missed. It also matches inside another header, such as X-Original-Content-Length:. And strtol accepts a leading + or -, and on overflow silently returns LONG_MAX. The RFC requires Content-Length to be only digits, and a message with two different Content-Length values must be rejected. A correct parser works on already-split header fields, compares names case-insensitively, and treats any malformed length as a hard error rather than “no body”.


Response Parsing

Status Line Parsing

struct ParsedStatusLine {
    std::string version;   // HTTP/1.1
    int status_code;       // 200, 404, ...
    std::string reason;    // OK, Not Found, ...
};
ParsedStatusLine parse_status_line(const std::string& line) {
    std::istringstream iss(line);
    ParsedStatusLine result;
    if (!(iss >> result.version >> result.status_code)) {
        throw std::runtime_error("Invalid status line");
    }
    std::getline(iss, result.reason);  // Rest: " OK\r" or " OK"
    // Trim whitespace
    result.reason.erase(0, result.reason.find_first_not_of(" \t\r\n"));
    result.reason.erase(result.reason.find_last_not_of(" \t\r\n") + 1);
    return result;
}
// Usage
// parse_status_line("HTTP/1.1 200 OK")  → 200, "OK"
// parse_status_line("HTTP/1.1 404 Not Found")  → 404, "Not Found"

Response Body Reading Strategy

// Body reading strategy
enum class BodyReadStrategy {
    NoBody,           // HEAD, 204, 304, etc.
    ContentLength,    // Content-Length present
    Chunked,          // Transfer-Encoding: chunked
    UntilClose        // HTTP/1.0, read until connection close
};
BodyReadStrategy determine_strategy(
    int status_code,
    const std::string& method,
    const std::map<std::string, std::string>& headers)
{
    if (method == "HEAD" || status_code == 204 || status_code == 304) {
        return BodyReadStrategy::NoBody;
    }
    auto it = headers.find("transfer-encoding");
    if (it != headers.end() &&
        it->second.find("chunked") != std::string::npos) {
        return BodyReadStrategy::Chunked;
    }
    if (headers.count("content-length")) {
        return BodyReadStrategy::ContentLength;
    }
    return BodyReadStrategy::UntilClose;  // HTTP/1.0 fallback
}

The order of these checks is the actual rule from RFC 9112, section 6.3, and it is not intuitive. The request method matters for a response: a response to HEAD carries Content-Length describing the body a GET would return, but has no body at all, so a client that forgets which request it sent will wait for bytes that never come. The same applies to 1xx informational responses (the code above omits them) and to a 2xx response to CONNECT, after which the connection becomes a tunnel.

Transfer-Encoding is checked before Content-Length because it wins when both are present. The substring search for "chunked" is itself too loose: the rule is that chunked must be the final transfer coding, and a value like gzip, chunked means the chunked framing contains gzip data. UntilClose is only valid for responses. A request without Content-Length or Transfer-Encoding simply has no body, because a client cannot signal the end of a request by closing the connection and still receive the response.


Header Handling

Header Parsing (Case-Insensitive)

#include <map>
#include <algorithm>
#include <cctype>
std::map<std::string, std::string> parse_headers(const std::string& header_block) {
    std::map<std::string, std::string> headers;
    std::istringstream iss(header_block);
    std::string line;
    while (std::getline(iss, line) && !line.empty() &&
           (line.back() == '\r' ? (line.pop_back(), true) : true)) {
        auto colon = line.find(':');
        if (colon == std::string::npos) continue;
        std::string name = line.substr(0, colon);
        std::string value = line.substr(colon + 1);
        // Trim whitespace
        value.erase(0, value.find_first_not_of(" \t"));
        value.erase(value.find_last_not_of(" \t\r\n") + 1);
        // Normalize header name to lowercase (HTTP headers are case-insensitive)
        std::transform(name.begin(), name.end(), name.begin(),
            [](unsigned char c) { return std::tolower(c); });
        // Multiple headers with same name: Set-Cookie, etc. need special handling
        if (headers.count(name)) {
            headers[name] += ", " + value;  // Simple merge
        } else {
            headers[name] = value;
        }
    }
    return headers;
}

Lowercasing the name before storing it is the right idea, since field names are case-insensitive while values generally are not. The merge step is where this parser becomes wrong. Combining repeated fields with ", " is allowed only for fields whose grammar is a comma-separated list, such as Accept or Cache-Control. Set-Cookie is the well-known exception: cookie attributes like Expires=Wed, 21 Oct 2026 07:28:00 GMT contain commas, so after merging you can no longer split the cookies apart reliably. That is why Beast’s fields container keeps every occurrence as a separate entry, and you retrieve them with req.equal_range(http::field::set_cookie). For duplicated Host or Content-Length headers, merging is also wrong: those should make the request fail with 400.

Two more things the parser silently gets wrong: a line without a colon is skipped instead of rejected, and whitespace between the field name and the colon (Host : example.com) is accepted, although the RFC requires a server to reject it with 400 precisely because proxies handled it inconsistently. Obsolete line folding (a header value continued on the next line starting with a space) is also not handled.

Key Headers

HeaderPurposeExample
Content-TypeBody MIME typeapplication/json, text/html
Content-LengthBody byte count1024
Transfer-EncodingTransfer encodingchunked
HostRequest target hostexample.com:8080
ConnectionConnection persistencekeep-alive, close
Accept-EncodingCompression supportgzip, deflate, br

Content-Type Parsing (MIME + charset)

struct ParsedContentType {
    std::string media_type;   // application/json
    std::string charset;      // utf-8 (if present)
};
ParsedContentType parse_content_type(const std::string& value) {
    ParsedContentType result;
    auto semicolon = value.find(';');
    result.media_type = value.substr(0, semicolon);
    result.media_type.erase(0, result.media_type.find_first_not_of(" \t"));
    result.media_type.erase(result.media_type.find_last_not_of(" \t") + 1);
    if (semicolon != std::string::npos) {
        std::string rest = value.substr(semicolon + 1);
        auto eq = rest.find('=');
        if (eq != std::string::npos) {
            std::string key = rest.substr(0, eq);
            std::string val = rest.substr(eq + 1);
            // Remove whitespace and quotes
            val.erase(0, val.find_first_not_of(" \t\""));
            val.erase(val.find_last_not_of(" \t\"") + 1);
            if (key.find("charset") != std::string::npos) {
                result.charset = val;
            }
        }
    }
    return result;
}

Chunked Encoding

Chunk Format

5\r\n
Hello\r\n
6\r\n
 World\r\n
0\r\n
\r\n

Format: [hex size]\r\n[data]\r\n repeated, ending with 0\r\n\r\n

Chunked encoding exists because the sender doesn’t always know the body length in advance: a server streaming a generated report, a proxy relaying a response it hasn’t finished receiving, or a long-poll endpoint. Instead of buffering everything to compute Content-Length, the sender frames the body as length-prefixed pieces. The full grammar is richer than the example: the size line may carry chunk extensions (5;name=value\r\n), and after the zero-size chunk there may be trailer fields before the final blank line, which is how a sender can append a checksum or a status computed after streaming the body.

Chunk Decoding Implementation

#include <vector>
#include <cctype>
std::pair<std::vector<char>, size_t> decode_chunk(
    const char* data, size_t size, size_t& consumed)
{
    std::vector<char> body;
    consumed = 0;
    const char* p = data;
    const char* end = data + size;
    while (p < end) {
        // Read chunk size (hex)
        if (p + 2 > end) break;  // Need at least "0\r\n"
        char* hex_end;
        unsigned long chunk_size = std::strtoul(p, &hex_end, 16);
        p = hex_end;
        // Skip \r\n
        if (p + 2 > end) break;
        if (p[0] != '\r' || p[1] != '\n') {
            throw std::runtime_error("Invalid chunk: expected CRLF");
        }
        p += 2;
        consumed = p - data;
        if (chunk_size == 0) {
            // Last chunk, may have trailing \r\n
            if (p + 2 <= end && p[0] == '\r' && p[1] == '\n') {
                consumed += 2;
            }
            break;
        }
        // Chunk data
        if (p + chunk_size + 2 > end) {
            break;  // Insufficient data
        }
        body.insert(body.end(), p, p + chunk_size);
        p += chunk_size;
        consumed = p - data;
        if (p[0] != '\r' || p[1] != '\n') {
            throw std::runtime_error("Invalid chunk: expected CRLF after data");
        }
        p += 2;
        consumed = p - data;
    }
    return {body, consumed};
}

The decoder is written to be resumable: consumed tells the caller how many bytes of the input were fully processed, so when a chunk is split across two TCP reads, the caller keeps the unconsumed tail and calls again when more data arrives. That incremental design is the core difficulty of any real HTTP parser.

The sketch still has gaps you would hit in production. strtoul reads until it finds a non-hex character and has no idea where the buffer ends, so on a buffer that isn’t NUL-terminated it can read past end. It skips leading whitespace and accepts 0x prefixes and signs, none of which are valid chunk sizes. A chunk extension (5;foo=bar) makes the “expected CRLF” check throw on valid input. There is no upper bound on chunk_size or on the accumulated body, so a peer can declare a chunk of ffffffffffffffff bytes. And trailers are not parsed: the 0\r\n branch assumes the next two bytes are the final CRLF. When a response is truncated by an early break, the partially decoded body is also returned alongside a smaller consumed, so a caller that appends the result on every call ends up with duplicated data.

Chunked Encoding Visualization

flowchart LR
    subgraph Chunked[Chunked Encoding]
        C1[5\r\nHello\r\n]
        C2["6\r\n World\r\n"]
        C3[0\r\n\r\n]
    end
    C1 --> C2 --> C3
    subgraph Decoded[Decoded Result]
        D["Hello World"]
    end
    Chunked -->|decode_chunk| Decoded

Beast-Based Complete Parser

Reading HTTP Request with Beast

#include <boost/beast.hpp>
#include <boost/asio.hpp>
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;
void read_http_request(tcp::socket& socket) {
    beast::flat_buffer buffer;
    http::request<http::string_body> req;
    beast::error_code ec;
    http::read(socket, buffer, req, ec);
    if (ec) {
        if (ec == http::error::end_of_stream) {
            // Connection closed (normal)
            return;
        }
        std::cerr << "Read error: " << ec.message() << "\n";
        return;
    }
    // Use parsed request
    std::cout << "Method: " << req.method_string() << "\n";
    std::cout << "Path: " << req.target() << "\n";
    std::cout << "Version: " << req.version() << "\n";
    for (const auto& field : req) {
        std::cout << field.name() << ": " << field.value() << "\n";
    }
    std::cout << "Body: " << req.body() << "\n";
}

Three objects cooperate here, and understanding the split explains most Beast code. The buffer (flat_buffer) holds bytes read from the socket that haven’t been consumed yet. http::read reads in large pieces, so after it returns, the buffer may already contain the beginning of the next request on the connection; that is why the buffer must outlive a single request and must not be cleared between them. The message (http::request<Body>) is the parsed result, and its Body type decides how the payload is stored. The parser (used implicitly here, explicitly below) is the state machine that applies the framing rules and enforces limits.

http::error::end_of_stream means the peer closed the connection cleanly between messages, which is how every keep-alive connection eventually ends, so it is not an error to log. If the connection closes in the middle of a message, you get http::error::partial_message instead.

Reading HTTP Response with Beast (Automatic Chunked Handling)

void read_http_response(beast::tcp_stream& stream) {
    beast::flat_buffer buffer;
    http::response_parser<http::string_body> parser;
    parser.body_limit(std::numeric_limits<std::uint64_t>::max());  // Removes the limit: only for trusted servers
    beast::error_code ec;
    http::read(stream, buffer, parser, ec);
    if (ec) {
        std::cerr << "Read error: " << ec.message() << "\n";
        return;
    }
    auto res = parser.get();
    std::cout << "Status: " << res.result_int() << "\n";
    std::cout << "Body: " << res.body() << "\n";
    // Beast automatically decodes Transfer-Encoding: chunked
}

The explicit response_parser is needed only to change parser settings. Beast’s parser defaults to a body limit of 1 MB for requests and 8 MB for responses, and a header limit of 8 KB. The defaults are deliberately small: a server that reads into string_body with no limit lets any client make it allocate as much memory as the client claims to send. The code above removes the limit entirely, which is acceptable only when you trust the server on the other end. Note also that Beast decodes the chunked framing, but it does not decompress Content-Encoding: gzip; that is a separate layer you handle yourself.

Async Request Reading

void do_read_async(beast::tcp_stream& stream,
    std::function<void(http::request<http::string_body>)> on_request)
{
    auto buffer = std::make_shared<beast::flat_buffer>();
    auto req = std::make_shared<http::request<http::string_body>>();
    http::async_read(stream, *buffer, *req,
        [&stream, buffer, req, on_request](beast::error_code ec, std::size_t) {
            if (ec) {
                if (ec != http::error::end_of_stream) {
                    std::cerr << "Read error: " << ec.message() << "\n";
                }
                return;
            }
            on_request(std::move(*req));
        });
}

In the async version, the buffer and request are held by shared_ptr because async_read returns immediately and the operation continues after do_read_async has returned. Anything the operation writes into must stay alive until the completion handler runs. Capturing &stream by reference is only safe if the caller guarantees the stream outlives the operation; the session class below solves that more cleanly by making the stream, buffer and message members of an object kept alive by shared_from_this().

HTTP Response Generation and Sending

void send_json_response(beast::tcp_stream& stream,
    unsigned status, const std::string& json_body)
{
    http::response<http::string_body> res{http::status::ok, 11};
    res.set(http::field::server, "MyServer/1.0");
    res.set(http::field::content_type, "application/json");
    res.body() = json_body;
    res.prepare_payload();  // Auto-set Content-Length
    if (status != 200) {
        res.result(static_cast<http::status>(status));
    }
    beast::error_code ec;
    http::write(stream, res, ec);
    if (ec) {
        std::cerr << "Write error: " << ec.message() << "\n";
    }
}

Complete HTTP Server Example (Beast)

#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <iostream>
#include <memory>
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;
class HttpSession : public std::enable_shared_from_this<HttpSession> {
    beast::tcp_stream stream_;
    beast::flat_buffer buffer_;
    http::request<http::string_body> req_;
    http::response<http::string_body> res_;  // member: must outlive async_write
public:
    explicit HttpSession(tcp::socket socket)
        : stream_(std::move(socket)) {}
    void start() { do_read(); }
private:
    void do_read() {
        req_ = {};  // reset the message, but keep buffer_: it may hold the next pipelined request
        stream_.expires_after(std::chrono::seconds(30));
        auto self = shared_from_this();
        http::async_read(stream_, buffer_, req_,
            [self, this](beast::error_code ec, std::size_t) {
                if (ec) {
                    if (ec != http::error::end_of_stream)
                        std::cerr << "read: " << ec.message() << "\n";
                    return;
                }
                handle_request();
            });
    }
    void handle_request() {
        res_ = http::response<http::string_body>{http::status::ok, req_.version()};
        auto& res = res_;
        res.keep_alive(req_.keep_alive());
        res.set(http::field::server, "Beast-HTTP-Server");
        res.set(http::field::content_type, "text/plain");
        if (req_.method() == http::verb::get && req_.target() == "/") {
            res.body() = "Hello, World!";
        } else if (req_.method() == http::verb::get &&
                   req_.target().starts_with("/api/")) {
            res.set(http::field::content_type, "application/json");
            res.body() = "{\"message\":\"API response\"}";
        } else {
            res.result(http::status::not_found);
            res.body() = "Not Found";
        }
        res.prepare_payload();
        auto self = shared_from_this();
        http::async_write(stream_, res,
            [self, this](beast::error_code ec, std::size_t) {
                if (!ec) {
                    if (req_.keep_alive()) {
                        do_read();  // Keep-Alive: next request
                        return;
                    }
                    beast::error_code sec;
                    stream_.socket().shutdown(tcp::socket::shutdown_send, sec);
                }
            });
    }
};
int main() {
    net::io_context ioc;
    tcp::acceptor acceptor(ioc, {tcp::v4(), 8080});
    std::function<void()> do_accept;
    do_accept = [&]() {
        acceptor.async_accept(
            [&](beast::error_code ec, tcp::socket socket) {
                if (!ec) {
                    std::make_shared<HttpSession>(std::move(socket))->start();
                }
                do_accept();
            });
    };
    do_accept();
    std::cout << "HTTP server on :8080\n";
    ioc.run();
}

The lifetime design is the part worth studying. Each accepted socket becomes an HttpSession owned by a shared_ptr, and every async operation captures self, so the session stays alive exactly as long as some operation is pending. When the last handler returns without starting a new operation (an error, or a non-keep-alive request), the reference count drops to zero and the destructor closes the socket. There is no explicit session list to clean up.

The response is a member (res_) rather than a local in handle_request for the same reason. async_write only stores a reference to the message; a local would be destroyed when handle_request returns, while the write is still in progress. Writing a local response with async_write is one of the most common Beast bugs: it often appears to work for small bodies and then produces garbage or a crash under load. res.keep_alive(req_.keep_alive()) makes the response carry the right Connection semantics for the request’s HTTP version, and when the connection is not being kept alive, shutdown(shutdown_send) sends a FIN after the response so the client sees a clean end of stream.

expires_after sets a deadline on the next operation on the tcp_stream. Without it, a client that opens a connection and sends nothing (or sends headers one byte per minute, the Slowloris pattern) holds a session forever. When the timer fires, the pending operation completes with beast::error::timeout.


Common Errors and Solutions

Problem 1: “end_of_stream” or “connection reset”

Cause: Client disconnects mid-request (browser refresh, timeout). In practice, the first time you put a Beast server behind a load balancer, the logs fill with end of stream and Connection reset by peer. Almost all of it is normal: browsers and load balancers close idle keep-alive connections whenever they like, and health checkers often connect and disconnect without sending a full request. Logging these as errors hides the real ones, so filter them out. Solution:

http::async_read(stream_, buffer_, req_,
    [self, this](beast::error_code ec, std::size_t) {
        if (ec) {
            if (ec == http::error::end_of_stream ||
                ec == net::error::connection_reset) {
                // Treat as normal connection close
                return;
            }
            std::cerr << "read error: " << ec.message() << "\n";
            return;
        }
        handle_request();
    });

Problem 2: “body limit exceeded”

Cause: Request body exceeds body_limit (DoS prevention default of 1 MB for request parsers). Typically seen the first time someone uploads a file or posts a large JSON document. The body limit can only be changed on a parser object, not on a plain http::request, which is why the fix switches to request_parser. After reading, parser.get() or parser.release() gives you the message. Respond with 413 Payload Too Large rather than just dropping the connection. Solution:

http::request_parser<http::string_body> parser;
parser.body_limit(10 * 1024 * 1024);  // 10MB
http::async_read(stream_, buffer_, parser, ...);

Problem 3: “partial message” or infinite read wait

Cause: Content-Length mismatch with actual body, or chunked encoding parsing error. Solution:

  • Beast handles automatically. For manual parsing, validate Content-Length.
  • Set timeout to prevent infinite wait:
stream_.expires_after(std::chrono::seconds(30));
http::async_read(stream_, buffer_, req_, handler);

Problem 4: Keep-Alive next request parsing failure

Cause: Multiple requests on one connection, and the previous message object is reused without being reset. http::read into a message that still has fields from the last request appends to it, so headers accumulate. Solution: reset the message, but do not clear the buffer. Leftover bytes in buffer_ after a read are the start of the next request (HTTP pipelining, or simply a fast client); http::read already consumed exactly the bytes of the message it parsed. Calling buffer_.consume(buffer_.size()) here throws away the next request, and the symptom is a client that hangs waiting for a response to a request the server never saw.

void do_read() {
    req_ = {};  // Reset request
    // Do not clear buffer_: it may already contain the next request
    http::async_read(stream_, buffer_, req_, ...);
}

Problem 5: Header Injection (CRLF Injection)

Cause: User input directly in headers allows \r\n to inject new headers. Solution:

// ❌ Dangerous
res.set("X-Custom", user_input);
// ✅ Safe: remove CRLF
std::string safe_value = user_input;
safe_value.erase(
    std::remove(safe_value.begin(), safe_value.end(), '\r'),
    safe_value.end());
safe_value.erase(
    std::remove(safe_value.begin(), safe_value.end(), '\n'),
    safe_value.end());
res.set("X-Custom", safe_value);

Don’t count on the HTTP library to catch this for you: behavior differs across libraries and versions, and the same bug appears in any code that assembles headers as strings, such as a Location redirect built from a query parameter. Stripping is the minimum; for values like redirect targets, validating against an allow-list is better.

Problem 6: Large Body Memory Explosion

Cause: string_body for 1GB file upload uses 1GB memory. Solution: Use dynamic_body or file_body:

http::request<http::dynamic_body> req;
// Or
http::request_parser<http::file_body> parser;
parser.body_limit(100 * 1024 * 1024);  // 100MB
boost::beast::file_mode mode = boost::beast::file_mode::write;
beast::error_code ec;
parser.get().body().open("/tmp/upload.dat", mode, ec);

dynamic_body stores the body in a multi_buffer (a list of chunks), which avoids reallocating one huge contiguous string but still keeps everything in memory. file_body streams the body straight to disk as it is parsed, so memory stays flat regardless of upload size. The file must be opened after the header is read but before the body is, which means using async_read_header first, inspecting Content-Length and the target, and only then opening the file and calling async_read for the rest. Opening a fixed path like /tmp/upload.dat is fine for illustration; with concurrent uploads each request needs its own temporary file.


Best Practices

Always Use Beast

// ❌ Manual parsing: edge case bugs
std::string path = extract_path(raw_request);
// ✅ Beast: RFC-compliant, validated
http::request<http::string_body> req;
http::read(socket, buffer, req);
std::string path = std::string(req.target());

Set body_limit

http::request_parser<http::string_body> parser;
parser.body_limit(1024 * 1024);  // 1MB limit (upload size limit)

Set Timeout

stream_.expires_after(std::chrono::seconds(30));

Call prepare_payload()

res.body() = "Hello";
res.prepare_payload();  // Auto-set Content-Length

Handle Keep-Alive

if (req.keep_alive()) {
    res.keep_alive(true);
    do_read();  // Wait for next request (after the write completes)
} else {
    res.keep_alive(false);
    stream_.socket().shutdown(tcp::socket::shutdown_send);  // after the write completes
}

This snippet compresses the decision; in async code both branches belong inside the write’s completion handler, as in the session example above. req.keep_alive() already applies the version-dependent default: HTTP/1.1 connections are persistent unless the client sends Connection: close, while HTTP/1.0 connections close unless the client sends Connection: keep-alive.

Consistent Error Responses

std::string escape_json(const std::string& s) {
    std::string out;
    for (char c : s) {
        if (c == '"') out += "\\\"";
        else if (c == '\\') out += "\\\\";
        else if (c == '\n') out += "\\n";
        else if (c == '\r') out += "\\r";
        else out += c;
    }
    return out;
}
void send_error(beast::tcp_stream& stream, unsigned status,
    const std::string& message)
{
    http::response<http::string_body> res{
        static_cast<http::status>(status), 11};
    res.set(http::field::content_type, "application/json");
    res.body() = "{\"error\":\"" + escape_json(message) + "\"}";
    res.prepare_payload();
    http::write(stream, res);
}

Production Patterns

Pattern 1: Request Logging Middleware

void log_request(const http::request<http::string_body>& req) {
    auto now = std::chrono::system_clock::now();
    auto time = std::chrono::system_clock::to_time_t(now);
    std::cerr << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S")
              << " " << req.method_string() << " " << req.target()
              << " " << req.version() << "\n";
}

Pattern 2: Request Size Limits

constexpr size_t MAX_HEADER_SIZE = 8 * 1024;   // 8KB
constexpr size_t MAX_BODY_SIZE = 10 * 1024 * 1024;  // 10MB
http::request_parser<http::string_body> parser;
parser.header_limit(MAX_HEADER_SIZE);
parser.body_limit(MAX_BODY_SIZE);

Size limits bound how much memory a single request can consume; they are not rate limiting, which bounds how many requests a client can make. Both are needed. The header limit matters more than it looks: large cookies from other applications on the same domain can push legitimate requests past 8 KB, and the symptom is Beast failing the read with header limit exceeded, which the user sees as a dropped connection. If you raise it, raise it deliberately, and return 431 Request Header Fields Too Large instead of closing silently.

Pattern 3: Graceful Shutdown

std::atomic<bool> shutdown_requested{false};
void do_accept() {
    if (shutdown_requested) return;
    acceptor_.async_accept(
        [this](beast::error_code ec, tcp::socket socket) {
            if (shutdown_requested) return;
            if (!ec) {
                std::make_shared<HttpSession>(std::move(socket))->start();
            }
            do_accept();
        });
}
// SIGINT handler
void on_signal() {
    shutdown_requested = true;
    acceptor_.close();
}

on_signal must not be a raw POSIX signal handler: closing an acceptor is not async-signal-safe, and it touches the acceptor from outside the io_context thread. Use net::signal_set signals(ioc, SIGINT, SIGTERM); with signals.async_wait(...), which delivers the signal as an ordinary completion handler on the I/O thread. Closing the acceptor cancels the pending async_accept with operation_aborted; existing sessions keep running until their current request finishes, and ioc.run() returns once no work remains. Add a hard deadline so a stuck keep-alive client can’t delay shutdown indefinitely.

Pattern 4: Connection Pool (Client)

class HttpClientPool {
    net::io_context& ioc_;
    std::queue<std::unique_ptr<beast::tcp_stream>> pool_;
    std::mutex mtx_;
    tcp::resolver::results_type endpoints_;
public:
    // The callback receives ownership and hands the stream back via release_connection()
    using Callback = std::function<void(std::unique_ptr<beast::tcp_stream>)>;
    void get_connection(Callback callback) {
        std::unique_lock lock(mtx_);
        if (!pool_.empty()) {
            auto stream = std::move(pool_.front());
            pool_.pop();
            lock.unlock();
            callback(std::move(stream));
            return;
        }
        lock.unlock();
        auto stream = std::make_unique<beast::tcp_stream>(ioc_);
        auto* raw = stream.get();
        raw->async_connect(endpoints_,
            [cb = std::move(callback), s = std::move(stream)]
            (beast::error_code ec, const tcp::endpoint&) mutable {
                if (!ec) cb(std::move(s));
            });
    }
    void release_connection(std::unique_ptr<beast::tcp_stream> stream) {
        std::lock_guard lock(mtx_);
        pool_.push(std::move(stream));
    }
};

The obvious first draft of this pool has a lifetime bug that is worth calling out because it is so easy to write: it creates the stream in a local unique_ptr, captures only the raw pointer in the connect handler, and lets the unique_ptr go out of scope when get_connection returns. The handler then runs on a destroyed stream. Moving the unique_ptr into the handler (Asio accepts move-only handlers) keeps the stream alive until the connection completes, and passing ownership to the callback makes it explicit who must return the stream to the pool.

A pool also has to cope with stale connections: the server may close an idle keep-alive connection at any time, so the first write on a pooled stream can fail with broken pipe or the read can return end_of_stream immediately. Clients typically retry an idempotent request once on a fresh connection when that happens, and only return a stream to the pool if the response was read completely and did not say Connection: close.

Pattern 5: Health Check Endpoint

if (req.target() == "/health") {
    res.result(http::status::ok);
    res.set(http::field::content_type, "application/json");
    res.body() = "{\"status\":\"ok\"}";
    res.prepare_payload();
    // Skip DB/cache checks for fast response
    return;
}

Pattern 6: CORS Headers

res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method() == http::verb::options) {
    res.result(http::status::ok);
    res.body() = "";
    res.prepare_payload();
    return;  // Preflight response
}

Access-Control-Allow-Origin: * is fine for public, cookie-less APIs, but browsers refuse to expose a response to a credentialed request (cookies, or fetch with credentials: 'include') when the origin is *. For those, echo back a specific allowed origin, add Vary: Origin so caches don’t serve one origin’s response to another, and set Access-Control-Allow-Credentials: true. Preflight responses commonly use 204 No Content, and Access-Control-Max-Age avoids a preflight before every request.


Implementation Checklist

  • Use Beast http::read/http::write (avoid manual parsing)
  • Set body_limit (DoS prevention)
  • Set expires_after timeout
  • Call prepare_payload()
  • Handle Keep-Alive (reset req_ = {}, keep leftover bytes in buffer_)
  • Prevent CRLF injection (validate header values)
  • Consistent error responses (JSON format)
  • Logging middleware
  • Graceful shutdown

References


The framing bug a hand-written parser gets wrong

ComponentRequestResponse
First lineGET /path HTTP/1.1HTTP/1.1 200 OK
HeadersHost: example.comContent-Type: application/json
Blank line\r\n\r\n
BodyJSON, form data, etc.JSON, HTML, etc.

Most parsing bugs are harmless: a rejected request or a garbled header. The one that is a security problem is getting the body length wrong. HTTP/1.1 has two ways to say where a body ends, Content-Length and Transfer-Encoding: chunked, and a message can arrive with both, or with two different Content-Length values. RFC 9112 says Transfer-Encoding takes precedence and that such messages should be treated as an error. If your server and a proxy in front of it disagree about where one request ends, an attacker can hide a second request inside the first one’s body (request smuggling). This is the strongest reason to use a maintained parser such as Beast’s instead of splitting on \r\n by hand, and to close the connection rather than guess when framing headers conflict.


Let Beast parse HTTP instead of hand-rolled string splitting; it follows the RFC, decodes chunked bodies and detects message boundaries for you.


Frequently Asked Questions (FAQ)

Q. What should a parser do when a message has both Content-Length and Transfer-Encoding: chunked?

A. Per RFC 9112, Transfer-Encoding overrides Content-Length, and a server may reject such a request outright. Proxies and backends disagreeing on this rule is the basis of HTTP request smuggling, so for a server the safest choice is to answer 400 and close the connection. Either way, never trust the Content-Length value when chunked encoding is present.