Skip to main content
boostlibs u/boostlibs avatar

Boost C++ Libraries

u/boostlibs

Feed options
Hot
New
Top
View
Card
Compact

Why we put chat messages in Redis streams (and plan to move old ones to MySQL)
Image
r/cpp
Why we put chat messages in Redis streams (and plan to move old ones to MySQL)

The BoostServerTech Chat project stores every message in Redis. An in-memory data store that Rubén Pérez (@anarthal) already knows will need to be replaced for older messages down the road.

He did it anyway. Here's why and what the code looks like.

Rubén is the author of Boost.MySQL and co-maintainer of Boost.Redis. He built this chat server as a case study in composing Boost libraries for a real application.

The fit

Chat messages have a specific access pattern: append only, read backward (newest first), scoped to a room. Redis streams match this almost exactly. Each room (chat group) is a stream. Writing a message is XADD. Reading history is XREVRANGE. Redis assigns each entry a unique, time ordered ID, so you get message ordering and cursor-based pagination for free. No schema migrations, indexing decisions, or ORM.

A SQL table could do this. But messages are generated at a fast pace and most SQL databases would struggle with this insertion heavy flow. It would require serious performance tuning for a workload that Redis handles natively.

Storing a message

When a user sends a message, the server appends it to the room's Redis stream. The "*" tells Redis to auto assign a stream ID:

// Compose the request. XADD appends to the room's stream
// and auto-assigns an ID.
redis::request req;
for (const auto& msg : messages)
    req.push("XADD", room_id, "*", "payload",
             serialize_redis_message(msg));
// Execute it. All XADDs go out in one round trip.
redis::generic_response res;
error_code ec;
co_await conn_.async_exec(req, res, asio::redirect_error(ec));

Three things worth noting:

  1. Multiple XADD commands get pushed into a single redis::request. Boost.Redis pipelines them over one connection, so even if a client sends several messages at once, it's one round trip.

  2. This is a C++20 coroutine. The co_await suspends until Redis responds, but the thread is free to handle other work while it waits.

  3. XADD accepts an arbitrary list of (key, value) string pairs. We are using a single key named “payload” that contains the message serialized as JSON. This allows arbitrary nesting.

Serialization without boilerplate

Each message is stored as a JSON payload inside the stream entry. The wire format is a simple struct:

struct redis_wire_message
{
    std::string_view content;
    std::int64_t timestamp;
    std::int64_t user_id;
};
BOOST_DESCRIBE_STRUCT(redis_wire_message, (), (content, timestamp, user_id))

That BOOST_DESCRIBE_STRUCT macro registers the struct's members for compile time reflection. Boost.JSON picks it up automatically: boost::json::value_from(msg) serializes it, boost::json::try_value_to<redis_wire_message>(jv) deserializes it. No hand-written to_json/from_json functions. Add a field to the struct and the serialization updates itself.

This is one of those spots where Boost libraries click together in a way that's hard to replicate with unrelated dependencies. Describe provides the reflection, JSON consumes it. Three lines replace what would otherwise be two hand maintained serialization functions.

The tradeoff

Redis keeps everything in memory. That's what makes it fast, and it's also the obvious problem. Right now, the server runs with Redis persistence enabled, so data survives restarts. But as message volume grows, keeping the full history in RAM stops making sense.

The plan is to eventually offload old messages to MySQL for archival. The message layer is already isolated behind its own service interface, so swapping in a tiered storage strategy (recent messages from Redis, older ones from MySQL) touches one component. Nothing else needs to know.

But "eventually" involves a lot. The migration boundary is full of questions. Do you move messages after a time window? After a count threshold? Do you do it inline during reads, or as a background job? What happens to cursor based pagination when the data lives in two places?

If you've built a system that migrated data from a fast ephemeral store to a slower durable one, what triggered the migration and what surprised you about it? Rubén is interested in hearing what actually worked.



What Happens When You Build a Chat Server on One Thread?
Image
r/programming
What Happens When You Build a Chat Server on One Thread?
What Happens When You Build a Chat Server on One Thread?

Rubén Pérez, author of Boost.MySQL and co-maintainer of Boost.Redis, built a group chat server to show how Boost libraries work together in a real application. A working server with authentication, persistent message history, real-time broadcasting, and a React frontend. Something you can fork and deploy.

The project is called BoostServerTech Chat. It runs a single C++ process that handles HTTP, WebSocket, Redis, and MySQL connections, all on one thread. This post covers why that design holds up, what it looks like in practice, and where it comes apart.

The Stack

The server sits behind a React/Next.js frontend and talks to two backing stores: Redis for chat messages and sessions (stored as streams), and MySQL for user accounts. The C++ process does everything else: serves the static frontend files, exposes a REST API for login and account creation, and upgrades HTTP connections to WebSocket for real-time messaging.

HTTP handles requests without tight latency requirements, like account creation and authentication. Messages go over WebSocket to keep latency low.

When a user types a message, the frontend sends it to the server over WebSocket. The server persists it to a Redis stream and broadcasts it to other connected clients.

What Coroutines Look Like Here

The server is fully asynchronous, using C++20 coroutines through Boost.Asio. If you haven't used them: you write async code that reads like synchronous code. You get the performance of asynchrony without the callback tangle.

Here is a snippet from the HTTP session handler:

// Handle a regular HTTP request by querying
// the backend databases as required
http::message_generator msg =
    co_await handle_http_request(
        parser.release(), *state
    );
// Determine if we should close the connection
bool keep_alive = msg.keep_alive();
// Send the response
co_await beast::async_write(
    stream, std::move(msg),
    asio::redirect_error(ec)
);

Full source: server/src/http_session.cpp

Don't worry about every detail here. The key point: when execution reaches co_await handle_http_request(...), the server sends a query to Redis or MySQL. The coroutine suspends until the database responds. Meanwhile, other work runs on the same thread. When the response arrives, the coroutine picks up right where it left off.

Compare this to callback-based Asio code. The same logic used to require nested lambdas, explicit state machines, and careful lifetime management. Coroutines flatten all of that into something that reads like a straight line.

One Thread, No Locks

Here is the event loop setup in main.cpp:

// The server is single-threaded, so we set the
// concurrency hint to 1
asio::io_context ctx(1);

Full source: server/src/main.cpp

One io_context, one thread calling ctx.run(). Every connection, every database call, every WebSocket frame goes through the same event loop.

The payoff: shared mutable state needs zero synchronization. The server keeps an in-memory structure tracking which clients subscribe to which chat rooms. In a multi-threaded server, every access to that structure needs a strand, and getting multi-threaded Asio right is not trivial. Here, it is just a container. No locks, no races, no ordering bugs that surface under load at 2 AM.

This works because all I/O is asynchronous. A MySQL query does not block the thread. It yields, other coroutines run, and when the response arrives, the original coroutine resumes.

How Services Compose

All services live in a shared_state object passed to every session:

class shared_state
{
    struct
    {
        std::string doc_root_;
        std::unique_ptr<redis_client> redis_;
        std::unique_ptr<mysql_client> mysql_;
        std::unique_ptr<cookie_auth_service> cookie_auth_;
        std::unique_ptr<pubsub_service> pubsub_;
    } impl_;
};

Full source: server/include/shared_state.hpp

Each service is an interface with an async implementation behind it, which keeps compilation fast. The Redis client holds a single persistent connection, as the Boost.Redis docs recommend. The MySQL client uses a connection pool. The pub/sub service is an in-memory container built on Boost.MultiIndex. They all share the same io_context, cooperating on one thread with no explicit coordination.

Where This Breaks Down

The obvious limitation: one CPU core. For a chat server, that is fine. The thread spends nearly all its time waiting on network I/O. But CPU-intensive work per request (image processing, compression, heavy serialization) would block every other connection.

The subtler limitation: horizontal scaling. The pub/sub state lives in memory, so you cannot run two server instances behind a load balancer and expect messages to reach all clients. Rubén tracks this as a known next step: replacing the in-memory pub/sub with Redis channels or XREAD groups so multiple instances can share broadcast state.

Then there is the middle ground: would an io_context backed by a small thread pool with strands give meaningfully better throughput on a single machine? That is tracked as issue #25, with measurements still pending.

For anyone curious about where async C++ server design is heading more broadly, the Corosio project explores similar coroutine patterns in a different context.

The Full Picture

The entire server is around 3,000 lines of C++. It composes key Boost libraries (Asio, Beast, Redis, MySQL, JSON, Describe, MultiIndex, URL, and Test) into an application you can fork, build with CMake, and deploy in Docker. No framework, no abstraction layer hiding the details. Every layer is in the source.

The BoostServerTech Chat repo has the full code, build instructions, and architecture docs. Rubén will be in the comments.

A question worth discussing: for I/O-bound services like this, is there a real-world case where a multi-threaded io_context with strands earns its complexity? Or is single-threaded the right default until measurements say otherwise?

upvotes comments