Boost.Beast REST Server Mechanics: Parser Limits, Keep-Alive, Timeouts and CORS [#31-2]

This article builds a small REST server directly on Boost.Beast and concentrates on the HTTP mechanics that Beast leaves to you: how a request is parsed, how a keep-alive connection loops, where body and header limits live, how timeouts are armed, and how a preflight request should be answered. It deliberately stops short of framework design. If you want an Express-style router with middleware chains, JWT authentication and request validation layered on top, that is covered in An Express-Style REST API Server in C++. Here the goal is that every line between the socket and your handler is something you understand.

Beast is not a web framework. It gives you HTTP message types, a parser and serializer, and asynchronous read/write operations on top of Asio. Everything else, including routing, connection lifetime, limits and timeouts, is your code. That is exactly why Beast servers work well in a demo and then misbehave under real clients: the defaults are reasonable for a library, but a server needs explicit decisions. If io_context, strands and completion handlers are new to you, read Boost.Asio Introduction first.

Requirements: Boost 1.75 or newer (for Boost.JSON; Beast itself has been in Boost since 1.66) or nlohmann/json 3.x, and a C++17 compiler. The snippets assume these aliases:

#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <optional>
#include <memory>
#include <chrono>

namespace beast = boost::beast;
namespace http  = beast::http;
namespace net   = boost::asio;
using tcp = net::ip::tcp;

Beast’s message and parser model

A Beast HTTP message is a plain value: http::request<Body> or http::response<Body>, where the Body type decides how the payload is stored. http::string_body keeps it in a std::string, which is what a JSON API wants. The message has no socket attached and does no I/O itself.

Reading is done by http::async_read(stream, buffer, target, handler). The target can be a message or a parser:

  • Passing a message is the shortcut. Beast builds a temporary parser internally with default settings. You cannot change the body or header limits this way.
  • Passing an http::request_parser<http::string_body> gives you control. The parser owns the message while it is being read; afterwards you call parser.release() (or parser.get()) to take it.

A parser handles exactly one message. For the next request on the same connection you need a new one, which is why the session below stores it in a std::optional and calls emplace() before every read. The same rule applies if you read into a bare message: the Beast examples reset it with req_ = {}; before each read, because reading into a message that still holds the previous request is not supported.

The beast::flat_buffer passed to async_read is different: it must live as long as the connection. The parser may read past the end of the current message (for example when a client pipelines two requests), and those extra bytes stay in the buffer for the next read. Creating a new buffer per request silently drops them.

Limits: body_limit and header_limit

http::request_parser ships with two limits:

LimitDefaultError when exceededSensible response
body_limit(n)1 MB for requestshttp::error::body_limit413 Payload Too Large
header_limit(n)8 KBhttp::error::header_limit431 Request Header Fields Too Large

Both are set on the parser before the read starts. body_limit also accepts boost::none to disable it, which you should essentially never do on a public port. When the request carries a Content-Length larger than the limit, the parser rejects it as soon as the header has been parsed, before any of the body is buffered. A chunked body is checked as it arrives.

Pick the body limit from your API contract, not from a round number. A JSON API that accepts user profiles has no reason to take 1 MB, while an endpoint that accepts uploads should probably not be a string_body endpoint at all. For uploads, Beast lets you read the header first with http::async_read_header, look at the route, and then choose a body type such as http::file_body and a limit specific to that route.

I have been caught by the default more than once. Everything works in development with small test payloads, then someone posts a larger document and the client reports only “connection reset”. The read handler returned on any error, so the server never sent a response at all. The fix is two lines, but the diagnosis takes a while because nothing is logged unless you log ec.message() on the read path. Since then I always handle body_limit explicitly and answer with 413 and Connection: close.

The session: async read, write and keep-alive

The session owns the stream, the buffer, the current parser and the response being written. The listener creates each socket on its own strand (section 5), so all of a session’s handlers are serialized even when io_context runs on several threads.

class Router;  // section 6

class HttpSession : public std::enable_shared_from_this<HttpSession> {
    beast::tcp_stream stream_;
    beast::flat_buffer buffer_;                                   // lives for the whole connection
    std::optional<http::request_parser<http::string_body>> parser_;
    http::response<http::string_body> res_;                       // must outlive async_write
    const Router& router_;

    static constexpr auto kReadTimeout  = std::chrono::seconds(30);
    static constexpr auto kWriteTimeout = std::chrono::seconds(30);

public:
    HttpSession(tcp::socket socket, const Router& router)
        : stream_(std::move(socket)), router_(router) {}

    void run() {
        // Start on the session's strand.
        net::dispatch(stream_.get_executor(),
            beast::bind_front_handler(&HttpSession::do_read, shared_from_this()));
    }

private:
    void do_read() {
        parser_.emplace();                       // fresh parser for every request
        parser_->body_limit(64 * 1024);          // what this API accepts
        parser_->header_limit(8 * 1024);

        stream_.expires_after(kReadTimeout);     // deadline for the whole message
        http::async_read(stream_, buffer_, *parser_,
            beast::bind_front_handler(&HttpSession::on_read, shared_from_this()));
    }

    void on_read(beast::error_code ec, std::size_t) {
        if (ec == http::error::end_of_stream)    // client closed an idle keep-alive connection
            return do_close();
        if (ec == http::error::body_limit)
            return send_error_and_close(http::status::payload_too_large, "request body too large");
        if (ec == http::error::header_limit)
            return send_error_and_close(http::status::request_header_fields_too_large, "headers too large");
        if (ec)                                  // beast::error::timeout, reset, bad request...
            return;                              // dropping the session closes the socket

        http::request<http::string_body> req = parser_->release();
        send(handle_request(req, router_));      // section 6
    }

    void send(http::response<http::string_body> res) {
        res_ = std::move(res);
        const bool close = res_.need_eof();

        stream_.expires_after(kWriteTimeout);
        http::async_write(stream_, res_,
            [self = shared_from_this(), close](beast::error_code ec, std::size_t) {
                if (ec) return;
                if (close) return self->do_close();
                self->do_read();                 // keep-alive: wait for the next request
            });
    }

    void send_error_and_close(http::status status, const char* message) {
        http::response<http::string_body> res{status, 11};
        res.set(http::field::content_type, "application/json");
        res.keep_alive(false);                   // the stream is out of sync; don't read again
        res.body() = std::string(R"({"error":")") + message + R"("})";
        res.prepare_payload();
        send(std::move(res));
    }

    void do_close() {
        beast::error_code ec;
        stream_.socket().shutdown(tcp::socket::shutdown_send, ec);
    }
};

A few details are easy to get wrong here.

The response must carry the request’s version and keep-alive intent. Every handler (or one central place, as in section 6) should do res.version(req.version()); res.keep_alive(req.keep_alive());. For HTTP/1.1 keep-alive is the default and keep_alive(false) adds Connection: close; for HTTP/1.0 it is the other way round. If you skip this, the response defaults can disagree with what the client asked for.

Use need_eof() to decide whether to close. res.need_eof() is true when the connection must end after this message, either because keep-alive is off or because the body is delimited by closing the connection (no Content-Length and not chunked). Calling prepare_payload() sets Content-Length for a string_body, so in practice need_eof() follows the keep-alive flag, but it also covers the case where you forgot prepare_payload().

res_ must outlive the write. async_write keeps a reference to the message, so it lives in the session, not in a local variable inside on_read.

The keep-alive branch has to read again. The session stays alive only as long as some pending operation holds a shared_from_this() copy. If the write completion handler neither reads nor closes, the last reference disappears and the connection is closed without any error being logged.

That last point is the bug I see most often in hand-written Beast servers, and I wrote it myself the first time. With curl everything looks fine, because curl sends one request and closes. Then a browser or a load-testing tool reuses the connection, and the second request either hangs until the client gives up or gets a reset. In my case the cause was reading the second request into the same http::request object that still held the first one. Moving to a parser in a std::optional, re-emplace()d at the top of do_read, made the lifecycle explicit and the problem went away.

Timeouts with tcp_stream::expires_after

beast::tcp_stream wraps a socket and adds a timer. expires_after(d) sets a deadline that applies to every asynchronous operation started on the stream until you change it. When the deadline passes, the pending operation completes with beast::error::timeout and the socket is closed.

Two properties matter for a server:

  1. The deadline covers the whole http::async_read, not each individual socket read. async_read is built from many smaller reads, but the deadline is set once before it starts. A client that sends one header byte every few seconds, the classic slowloris pattern, still runs out of time at the 30 second mark. A per-read idle timeout would never expire for such a client.
  2. Setting the deadline before the keep-alive read doubles as an idle timeout. When do_read runs after a response, the same 30 seconds bound how long an idle keep-alive connection may stay open.

Without any timeout, the defaults in Asio and Beast are “wait forever”. Connections from clients that disappeared without closing (mobile networks, crashed processes, half-open TCP) accumulate, each holding a file descriptor and a session object. I have watched a Beast server with no expires_after calls slowly climb in open descriptors until accept began failing with “too many open files”. Nothing was wrong with the request handling. Idle connections were simply never closed. If you only add one thing from this article, add the two expires_after calls.

Choose the values per phase if you need to. A short header timeout with a longer body timeout needs async_read_header followed by async_read with a new expires_after in between. If you run behind a reverse proxy, keep your idle timeout longer than the proxy’s upstream keep-alive timeout, so that the proxy, not your server, closes idle upstream connections. Otherwise the proxy can occasionally send a request on a connection your server is closing at the same moment.

The listener and a clean accept loop

class Listener : public std::enable_shared_from_this<Listener> {
    net::io_context& ioc_;
    tcp::acceptor acceptor_;
    const Router& router_;

public:
    Listener(net::io_context& ioc, tcp::endpoint ep, const Router& router)
        : ioc_(ioc), acceptor_(net::make_strand(ioc)), router_(router) {
        acceptor_.open(ep.protocol());
        acceptor_.set_option(net::socket_base::reuse_address(true));
        acceptor_.bind(ep);
        acceptor_.listen(net::socket_base::max_listen_connections);
    }

    void run() { do_accept(); }

    void stop() {
        net::post(acceptor_.get_executor(), [self = shared_from_this()] {
            beast::error_code ec;
            self->acceptor_.close(ec);           // pending async_accept -> operation_aborted
        });
    }

private:
    void do_accept() {
        // Each accepted socket gets its own strand.
        acceptor_.async_accept(net::make_strand(ioc_),
            [self = shared_from_this()](beast::error_code ec, tcp::socket socket) {
                if (ec == net::error::operation_aborted || !self->acceptor_.is_open())
                    return;                      // shutting down: stop accepting
                if (!ec)
                    std::make_shared<HttpSession>(std::move(socket), self->router_)->run();
                self->do_accept();               // other errors: keep accepting (on EMFILE this retries at once; a short timer is kinder)
            });
    }
};

The throwing overloads of open/bind/listen are used on purpose: a port that is already taken should stop the process at startup with a clear exception. The check for operation_aborted matters for shutdown. An accept loop that restarts unconditionally will spin on a closed acceptor instead of letting io_context::run() return.

A small router and the central request handler

The router only has to map a method and a path to a function that returns a response. Handlers return a response by value, which keeps the “who owns the response” question simple and lets the central handler add the headers every response needs.

#include <regex>
#include <functional>
#include <vector>
#include <string>

using Request  = http::request<http::string_body>;
using Response = http::response<http::string_body>;
using Params   = std::vector<std::string>;           // captured path segments, in order
using Handler  = std::function<Response(const Request&, const Params&)>;

Response json_response(const Request& req, http::status status, std::string body) {
    Response res{status, req.version()};
    res.set(http::field::content_type, "application/json");
    res.keep_alive(req.keep_alive());
    res.body() = std::move(body);
    res.prepare_payload();
    return res;
}

class Router {
    struct Route { http::verb method; std::regex pattern; Handler handler; };
    std::vector<Route> routes_;

public:
    // Patterns are regexes, e.g. R"(/api/users/(\d+))"
    void add(http::verb method, const std::string& pattern, Handler h) {
        routes_.push_back({method, std::regex("^" + pattern + "$"), std::move(h)});
    }

    Response dispatch(const Request& req) const {
        std::string path(req.target());
        if (auto q = path.find('?'); q != std::string::npos) path.resize(q);

        bool path_matched = false;
        std::string allow;
        for (const auto& r : routes_) {
            std::smatch m;
            if (!std::regex_match(path, m, r.pattern)) continue;
            path_matched = true;
            if (r.method != req.method()) {
                allow += (allow.empty() ? "" : ", ") + std::string(http::to_string(r.method));
                continue;
            }
            Params params;
            for (std::size_t i = 1; i < m.size(); ++i) params.push_back(m[i].str());
            return r.handler(req, params);
        }
        if (path_matched) {
            auto res = json_response(req, http::status::method_not_allowed,
                                     R"({"error":"method not allowed"})");
            res.set(http::field::allow, allow);
            return res;
        }
        return json_response(req, http::status::not_found, R"({"error":"not found"})");
    }
};

Distinguishing 404 from 405 costs a few lines and saves client developers real debugging time: “this URL exists but not with POST” is a different problem from “this URL does not exist”. RFC 9110 requires an Allow header on a 405 response, which the loop collects along the way.

Using \d+ in the pattern instead of a generic [^/]+ means /api/users/abc never reaches the handler, so a later std::stoi cannot throw on it. std::regex is slow compared to hand-written matching, but for a few dozen routes compiled once at startup it is not where the time goes. A trie-based router with :id placeholders and typed parameters belongs to the framework layer discussed in #50-2.

The central handler is where CORS, the exception boundary and the common headers go:

Response handle_request(const Request& req, const Router& router) {
    if (req.method() == http::verb::options)
        return cors_preflight(req);                   // section 8, before routing

    Response res;
    try {
        res = router.dispatch(req);
    } catch (const std::exception& e) {
        std::cerr << "handler error: " << e.what() << '\n';
        res = json_response(req, http::status::internal_server_error,
                            R"({"error":"internal server error"})");
    }
    add_cors_headers(req, res);                       // also on 4xx/5xx
    return res;
}

Handlers run on the session’s strand. A handler that blocks on a database or a slow upstream blocks every other connection sharing that thread. Either keep handlers non-blocking, or hand the work to a separate thread pool and net::post the finished response back to the session’s executor before calling send.

JSON bodies: nlohmann/json or Boost.JSON

Both libraries work. Boost.JSON avoids an extra dependency if you already use Boost and has a lower-allocation parser; nlohmann/json has the more convenient API. Whichever you choose, parse without throwing on the request path, check the content type, and check the shape of the document before reading fields.

#include <nlohmann/json.hpp>

Response create_user(const Request& req, const Params&) {
    auto ct = req[http::field::content_type];
    if (ct.substr(0, 16) != "application/json")
        return json_response(req, http::status::unsupported_media_type,
                             R"({"error":"expected application/json"})");

    auto j = nlohmann::json::parse(req.body(), nullptr, /*allow_exceptions=*/false);
    if (j.is_discarded() || !j.is_object())
        return json_response(req, http::status::bad_request, R"({"error":"invalid JSON"})");

    if (!j.contains("name") || !j["name"].is_string() ||
        !j.contains("email") || !j["email"].is_string())
        return json_response(req, http::status::unprocessable_entity,
                             R"({"error":"name and email are required strings"})");

    std::string name  = j.at("name").get<std::string>();
    std::string email = j.at("email").get<std::string>();

    nlohmann::json out = {{"id", 3}, {"name", name}, {"email", email}};
    auto res = json_response(req, http::status::created, out.dump());
    res.set(http::field::location, "/api/users/3");
    return res;
}

Two nlohmann behaviours cause trouble in request handlers. On a non-const json, j["missing"] inserts a null member instead of failing, and converting that null to std::string then throws type_error. Checking with contains and reading with at avoids both. And json::parse throws parse_error by default; with allow_exceptions set to false it returns a discarded value you can test, which keeps a malformed body from ever reaching the exception boundary as a 500.

The same handler with Boost.JSON:

#include <boost/json.hpp>
namespace json = boost::json;

boost::system::error_code jec;
json::value v = json::parse(req.body(), jec);
if (jec || !v.is_object())
    return json_response(req, http::status::bad_request, R"({"error":"invalid JSON"})");

auto const& obj = v.as_object();
auto const* name = obj.if_contains("name");
if (!name || !name->is_string())
    return json_response(req, http::status::unprocessable_entity,
                         R"({"error":"name is required"})");

json::object out{{"id", 3}, {"name", *name}};
return json_response(req, http::status::created, json::serialize(out));

Hand-built JSON strings like R"({"error":"not found"})" are fine for fixed messages. Anything containing user input must go through the JSON library so that quotes and control characters are escaped.

Status codes this server actually returns

CodeWhen
200 OKsuccessful GET/PUT with a body
201 CreatedPOST created a resource (with a Location header)
204 No Contentsuccessful DELETE, CORS preflight
400 Bad Requestbody is not valid JSON
404 Not Foundno route matches the path
405 Method Not Allowedpath matches, method does not (with Allow)
413 Payload Too Largehttp::error::body_limit
415 Unsupported Media Typewrong Content-Type
422 Unprocessable Contentvalid JSON, wrong shape
431 Request Header Fields Too Largehttp::error::header_limit
500 Internal Server Errorexception escaped a handler

Keep the error body shape identical everywhere (here {"error": "..."}), including the 413 and 431 responses generated in the session. Clients write one error parser, not one per status.

CORS preflight done correctly

A browser sends a preflight OPTIONS request before a cross-origin request that uses methods other than GET/HEAD/POST, a Content-Type other than the three “simple” ones (so application/json triggers it), or custom headers such as Authorization. The preflight carries Origin, Access-Control-Request-Method and usually Access-Control-Request-Headers. It must succeed before the real request is sent, and it never carries credentials, so it must be answered before any authentication check.

#include <array>
#include <string_view>

constexpr std::array<std::string_view, 2> kAllowedOrigins = {
    "https://app.example.com", "http://localhost:5173"};

bool origin_allowed(beast::string_view origin) {
    for (auto o : kAllowedOrigins)
        if (origin == beast::string_view(o.data(), o.size())) return true;
    return false;
}

Response cors_preflight(const Request& req) {
    Response res{http::status::no_content, req.version()};
    res.keep_alive(req.keep_alive());
    auto origin = req[http::field::origin];
    if (origin_allowed(origin)) {
        res.set(http::field::access_control_allow_origin, origin);
        res.set(http::field::access_control_allow_methods, "GET, POST, PUT, DELETE");
        res.set(http::field::access_control_allow_headers, "Content-Type, Authorization");
        res.set(http::field::access_control_max_age, "600");
    }
    res.set(http::field::vary, "Origin");
    res.prepare_payload();
    return res;
}

void add_cors_headers(const Request& req, Response& res) {
    auto origin = req[http::field::origin];
    if (origin_allowed(origin))
        res.set(http::field::access_control_allow_origin, origin);
    res.set(http::field::vary, "Origin");
}

The points that usually go wrong:

  • Preflight hits the router. No route is registered for OPTIONS, so the router answers 404 or 405, the browser reports a CORS error, and the real request never arrives. Handling OPTIONS before routing, as handle_request does, fixes this for every route at once.
  • Error responses lack Access-Control-Allow-Origin. The preflight passes, the real request returns 401 or 500, and because the error response has no CORS header the browser hides it and shows a CORS error instead. add_cors_headers runs on every response.
  • Echoing the origin without Vary: Origin. When the allowed origin is reflected from the request, a shared cache in between can serve one origin’s response to another. Vary: Origin prevents that.
  • * with credentials. Access-Control-Allow-Origin: * is fine for a public API without cookies. If the browser sends credentials (credentials: "include"), the wildcard is rejected and you must echo a specific origin and add Access-Control-Allow-Credentials: true.

Putting it together: threads and graceful shutdown

#include <algorithm>
#include <thread>

int main() {
    const unsigned threads = std::max(1u, std::thread::hardware_concurrency());
    net::io_context ioc{static_cast<int>(threads)};

    Router router;                                    // fully built before any thread starts
    router.add(http::verb::get, "/api/health", [](const Request& req, const Params&) {
        return json_response(req, http::status::ok, R"({"status":"ok"})");
    });
    router.add(http::verb::get, R"(/api/users/(\d+))", [](const Request& req, const Params& p) {
        nlohmann::json out = {{"id", std::stoll(p[0])}, {"name", "Alice"}};
        return json_response(req, http::status::ok, out.dump());
    });
    router.add(http::verb::post, "/api/users", create_user);

    auto listener = std::make_shared<Listener>(
        ioc, tcp::endpoint{net::ip::make_address("127.0.0.1"), 8080}, router);
    listener->run();

    // Graceful shutdown: stop accepting, let in-flight requests finish,
    // force-stop after a grace period.
    net::signal_set signals(ioc, SIGINT, SIGTERM);
    net::steady_timer grace(ioc);
    signals.async_wait([&](beast::error_code ec, int) {
        if (ec) return;
        listener->stop();
        grace.expires_after(std::chrono::seconds(10));
        grace.async_wait([&](beast::error_code) { ioc.stop(); });
    });

    std::vector<std::thread> pool;
    for (unsigned i = 1; i < threads; ++i) pool.emplace_back([&] { ioc.run(); });
    ioc.run();
    for (auto& t : pool) t.join();
}

The router is shared by all sessions through a const& and is never modified after startup, so no locking is needed. The same would not be true for a route table you change at runtime.

The shutdown uses net::signal_set instead of a C signal handler. A common earlier version of this code set an std::atomic<bool> from std::signal and looped on ioc.run_one(). That loop blocks inside run_one() whenever nothing is happening, so the flag is only checked when the next I/O event arrives, which may be never on an idle server. signal_set delivers the signal as an ordinary completion handler on the io_context, where it can safely close the acceptor.

After listener->stop(), requests already being processed complete normally. Idle keep-alive connections do not close by themselves until their read timeout expires, which is why the grace timer eventually calls ioc.stop(). If you want a faster drain, keep a registry of sessions and have each one respond with keep_alive(false) once shutdown starts. Under systemd or Kubernetes, make the grace period shorter than the time the supervisor waits between SIGTERM and SIGKILL. #50-5 covers that side of deployment.

TLS and the reverse proxy

There are two reasonable places for TLS:

  • In the process, with beast::ssl_stream<beast::tcp_stream> and an ssl::context. The session gains an async_handshake before the first read and an async_shutdown instead of the TCP shutdown. Everything above stays the same. Certificate loading and handshake errors are covered in #30-2.
  • At a reverse proxy (nginx, HAProxy, a cloud load balancer), with the Beast server listening on plain HTTP on localhost or a private network. This is what I would choose for most deployments: certificate renewal, HTTP/2 towards clients and connection limits are handled by software built for it, and the C++ process stays small.

With a proxy in front, a few settings interact with the code above:

upstream api_backend {
    server 127.0.0.1:8080;
    keepalive 32;                       # reuse upstream connections
}

server {
    listen 443 ssl;
    server_name api.example.com;
    # ssl_certificate / ssl_certificate_key ...

    client_max_body_size 64k;           # match the Beast body_limit

    location /api/ {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;         # required for upstream keep-alive
        proxy_set_header Connection ""; # don't forward "close"
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

nginx speaks HTTP/1.0 to upstreams by default and closes each connection, so without proxy_http_version 1.1 and the empty Connection header, all the keep-alive logic in the session never runs. Keep client_max_body_size and body_limit consistent so that clients get the same 413 wherever the limit is hit. Finally, the peer address your server sees is now the proxy. Take the client address from X-Forwarded-For only when the request really came from your proxy, since any client can send that header directly.

Where each concern lives in a Beast server

ConcernWhere it lives in a Beast server
Request parsinghttp::request_parser<string_body>, one per request, released into a message
Body and header limitsparser.body_limit(), parser.header_limit() → 413 / 431
Keep-alivecopy req.keep_alive() to the response, close on res.need_eof(), otherwise read again
Timeoutstcp_stream::expires_after() before every read and write
Routingmethod + path table, 404 vs 405 with Allow
JSONnon-throwing parse, shape check, 400 vs 422
CORSOPTIONS handled before routing, allow-origin on every response, Vary: Origin
Shutdownnet::signal_set, close the acceptor, grace timer
TLSssl_stream in process, or terminate at a reverse proxy

References

Frequently Asked Questions (FAQ)

Q. Why does the browser still report a CORS error after I add Access-Control-Allow-Origin?

A. For requests with a JSON Content-Type or custom headers such as Authorization, the browser first sends an OPTIONS preflight, and if the router answers it with 404 or 405 the real request is never sent. Handle OPTIONS before routing and reply with Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers. Add Access-Control-Allow-Origin to error responses as well, otherwise the browser shows a CORS failure instead of the real status code.

Q. Why does my Beast server answer the first request on a connection but hang on the second?

A. The keep-alive branch after async_write has to start a fresh read with a fresh message or parser. If you reuse a request object that still holds the previous message, never call do_read again, or forget to copy the request’s keep-alive flag onto the response, the second request on the same connection either stalls or gets closed. Reset the parser (std::optional::emplace) before every read and decide whether to close with res.need_eof().

Q. Large POST bodies fail and the client just sees the connection drop. Why?

A. http::request_parser has a default body limit of 1 MB. A bigger body makes async_read complete with http::error::body_limit, and if the read handler simply returns on any error the socket is closed without a response. Set parser.body_limit() to what your API actually accepts and answer 413 with Connection: close when the limit is hit.

Previous article: A Multi-Client Chat Server with Boost.Asio [#31-1]