If you've ever tried to use socket.io-client-cpp in a project built in the last few years, you know the friction: it vendors RapidJSON, it vendors websocketpp, its CMake predates target_link_libraries being cool, and pulling it into a project that already depends on nlohmann/json and Boost.Asio means either forking it or living with two JSON libraries in your binary.
I needed a socket.io client for a system with existing nlohmann::json and Boost.Beast dependencies, so instead of fighting the old library I rewrote it. The result is sioxx — same job, different guts:
- 🔗 GitHub: jfayot/sioxx
- 🔗 Documentation: Read the docs
What changed vs. the original
socket.io-client-cpp |
sioxx |
|
|---|---|---|
| JSON | RapidJSON | nlohmann/json |
| WebSocket | websocketpp |
Boost.Beast (boost::asio + boost::beast::websocket) |
| Wire protocol | JSON only | JSON or MessagePack, selectable per client |
| Transport | WebSocket | WebSocket first, HTTP long-polling fallback |
| Reconnection | basic retry | capped exponential backoff with jitter |
| Build | vendored deps, older CMake | modern CMake + CPM.cmake, find_package, full install(EXPORT ...)
|
| Tests | Catch2 tests | GoogleTest suite (parsers, transports, URL parsing, reconnection, socket bookkeeping, engine.io framing) |
The public API keeps the same shape you'd expect from socket.io — client, socket("/namespace"), on(event, handler), emit(event, data), ack callbacks — but almost everything under the hood is new.
The JSON value problem, solved by not solving it
The original library has a whole sio::message class hierarchy — int_message, string_message, object_message, array_message, binary_message — basically reimplementing a JSON value type from scratch. But nlohmann::json is a JSON value type, and it already handles null/bool/int/double/string/array/object and raw binary via json::binary_t. So sioxx::message is just:
namespace sioxx {
using json = nlohmann::json;
using message = json; // that's it
using message_list = json; // always a JSON array
}
That one decision deletes an entire subsystem. Building event args is just building a json array:
sock->emit("hello", sioxx::json{"world"});
sock->emit("ping_ack", sioxx::json::array({1, 2, 3}), [](sioxx::message reply) {
std::cout << "ack: " << reply.dump() << "\n";
});
Two wire protocols behind one interface
socket.io actually has two parsers in the wild: the default text protocol, and socket.io-msgpack-parser for binary-heavy workloads. sioxx models this as a small strategy interface:
class parser_base {
public:
virtual void encode(const socketio_packet& packet, const frame_writer& write) const = 0;
virtual bool decode(const std::string& payload, bool is_binary, socketio_packet& out) const = 0;
virtual std::string name() const = 0;
};
json_parser implements the classic <type><nsp>,<id><json> text framing (2["chat message","hi"], 2/chat,3["ack me"], …).
msgpack_parser is more interesting: instead of pulling in a separate MessagePack library, it's built entirely on nlohmann::json::to_msgpack / from_msgpack. Each packet becomes one MessagePack-encoded map (with optional id and data fields):
void msgpack_parser::encode(const socketio_packet& packet, const frame_writer& write) const {
json obj = { {"type", (int)packet.type}, {"nsp", packet.nsp} };
if (packet.id >= 0) obj["id"] = packet.id;
if (!packet.data.is_null()) obj["data"] = packet.data;
auto bytes = json::to_msgpack(obj);
write(std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size()), /*is_binary=*/true);
}
The nice side effect: because json::binary_t maps straight onto MessagePack's bin type, binary payloads round-trip natively through the msgpack parser — no placeholder/reconstruction dance needed for BINARY_EVENT/BINARY_ACK, which the JSON protocol normally requires. Pick your parser with one field:
sioxx::client_options opts;
opts.parser = sioxx::parser_kind::msgpack; // or parser_kind::json (default)
sioxx::client client(opts);
Boost.Beast instead of websocketpp
websocket_transport owns its own boost::asio::io_context on a background thread, supports both ws:// and wss:// (TLS via OpenSSL, SNI included), and serializes writes through a small queue since Beast streams can't be written to concurrently:
class websocket_transport final : public transport_base,
public std::enable_shared_from_this<websocket_transport> {
public:
void connect(const std::string& url) override;
void send(const std::string& payload, bool is_binary) override;
void close() override;
// ...
};
transport_base is a tiny abstract interface: engine.io only knows how to open, send, close, and receive opaque frames. That made it possible to add a second concrete transport without changing the protocol layers above it.
HTTP long-polling when WebSocket is unavailable
WebSocket is still the preferred path, but sioxx now falls back to Engine.IO v4 HTTP long-polling if the initial WebSocket connection fails. The polling transport performs the normal Engine.IO GET/POST cycle, opens a fresh HTTP(S) connection for each poll or write, honors the same TLS and extra-header settings, and encodes binary polling frames as Engine.IO b + base64 packets.
The fallback is visible through the existing error listener:
[sioxx] error: WebSocket connection failed; switching to HTTP long-polling
You can also force polling from the start, which is useful for environments that block WebSocket upgrades and for testing:
sioxx::client_options opts;
opts.force_http_polling = true;
sioxx::client client(opts);
Layering: engine.io → socket.io → namespaces
The stack mirrors the real socket.io/engine.io split:
sioxx::client
└─ socketio_client_impl (packet dispatch, namespace registry)
└─ engineio_client (engine.io v4 handshake, ping/pong heartbeat)
└─ websocket_transport (Boost.Beast ws/wss)
or http_polling_transport (Engine.IO HTTP(S) polling)
engineio_client does the OPEN handshake, tracks pingInterval/pingTimeout from the server, and runs a heartbeat thread that closes the connection if pongs stop arriving. socketio_client_impl decodes packets with whichever parser_base you picked and routes EVENT/ACK/CONNECT/DISCONNECT packets to the right socketio_socket by namespace:
auto sock = client.socket("/your_namespace");
sock->on("your_message", [](const std::string& event, sioxx::message data) {
// data is a nlohmann::json array of the event's arguments
});
client.connect("wss://example.com");
Modern CMake, actually modern
This was half the point of the rewrite. CMakeLists.txt does what you'd expect from a modern C++ library:
-
nlohmann/jsonand Boost via CPM.cmake (or-DSIOXX_USE_SYSTEM_JSON=ONto use an installed nlohmann/json) - OpenSSL and Threads via
find_package - a proper
install(EXPORT sioxxTargets ...)+ generatedsioxxConfig.cmake, so downstream projects just do:
find_package(sioxx REQUIRED)
target_link_libraries(my_app PRIVATE sioxx::sioxx)
No manual include paths, no copy-pasting source files into your project.
The CMake switches are deliberately small: SIOXX_BUILD_EXAMPLES,
SIOXX_BUILD_TESTS, and SIOXX_USE_SYSTEM_JSON. The library requires CMake
3.20+ and a C++17 compiler.
Connection options and threading
client_options also exposes TLS verification and extra HTTP/WebSocket
upgrade headers, so development servers with self-signed certificates and
cookie/token-based deployments do not require a transport fork:
sioxx::client_options opts;
opts.verify_tls = false; // development/self-signed certificates only
opts.extra_headers = {{"Authorization", "Bearer example-token"}};
The transport runs its Asio work on a background thread. Event listeners,
ack callbacks, and open/close/error callbacks therefore run on that thread;
applications with a UI or another thread-affine runtime should dispatch work
back to their own queue.
Tests
There's a GoogleTest suite (also fetched through CPM.cmake) covering the parts that don't need a live server:
- parser round-trips — JSON and MessagePack encode/decode, namespaces, ack ids, malformed input, binary attachments
-
socketio_socketbookkeeping —on/off/emit/ack callbacks, tested with an emptyweak_ptr<socketio_client_impl>so no network is involved at all -
engineio_clientframing — handshake, ping→pong, message prefixing, binary passthrough — tested against a small in-memory faketransport_baseinstead of a real socket - polling framing — binary base64 packet encoding/decoding used by the HTTP polling transport
- URL parsing — ws/wss scheme, host, ports, paths, and invalid URLs
- reconnection policy — deterministic exponential growth, maximum-delay capping, and jitter bounds
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSIOXX_BUILD_TESTS=ON
cmake --build build -j
ctest --test-dir build --output-on-failure
The actual network transports are exercised end-to-end by the example client against the bundled Socket.IO server.
Reconnection that backs off instead of stampeding
Reconnects are configurable per client. Each attempt doubles the delay up to a cap, then applies symmetric jitter. The default is a 2-second initial delay, a 30-second cap, and a randomization factor of 0.5. Set attempts to zero to disable reconnection.
sioxx::client_options opts;
opts.reconnect_attempts = 5;
opts.reconnect_delay = std::chrono::milliseconds(1000);
opts.reconnect_delay_max = std::chrono::milliseconds(30000);
opts.reconnect_randomization_factor = 0.5;
Try it
sudo apt install libssl-dev cmake
git clone https://github.com/jfayot/sioxx
cmake -S sioxx -B sioxx/build -DCMAKE_BUILD_TYPE=Release
cmake --build sioxx/build -j
./sioxx/build/sioxx_basic_client
The repository also includes examples/test_server, a minimal Node.js Socket.IO server that mirrors the example namespace and events. It supports JSON or MessagePack and WebSocket+polling or polling-only modes:
cd sioxx/examples/test_server
pnpm install
pnpm start # JSON, WebSocket + polling
# or: pnpm start:msgpack
# or: pnpm start:polling # JSON, polling only
# or: pnpm start:msgpack-polling
# From the repository root:
./build/sioxx_basic_client polling
./build/sioxx_basic_client msgpack polling
The server logs the active transport, so a polling test visibly reports (polling) on connect.
GitHub Actions builds and tests the project on Ubuntu, macOS, and Windows with GCC, Clang, and MSVC. Pushing a v* tag runs that matrix and, after it succeeds, creates a GitHub Release with generated notes.
What's still missing
Being upfront about the gaps:
- The JSON parser recognizes
BINARY_EVENT/BINARY_ACKheaders but doesn't implement the placeholder reconstruction for multi-attachment binary payloads — use the MessagePack parser for binary data. - Long-polling is a fallback (or an explicitly forced mode); sioxx does not upgrade an established polling session back to WebSocket.
If any of that matters for your use case, PRs welcome: github.com/jfayot/sioxx
Top comments (4)
I particularly appreciate how
sioxxsimplifies the JSON value handling by leveragingnlohmann::jsondirectly, eliminating the need for a customsio::messageclass hierarchy. The use ofjsonas a type alias fornlohmann::jsonandmessagealso improves code readability. The support for both JSON and MessagePack wire protocols through a strategy interface is also a great design choice, allowing for flexibility without adding unnecessary complexity. Have you considered adding support for other serialization formats, such as CBOR or BSON, to further enhance the library's versatility?Thanks! CBOR and BSON would indeed be relatively straightforward to add, especially since "nlohmann::json" already provides built-in conversion functions for both formats.
The main consideration is interoperability: Socket.IO officially supports JSON, while MessagePack works through the existing "socket.io-msgpack-parser" ecosystem. Supporting another format would also require a compatible parser on the server side.
That said, the strategy-based parser interface was designed precisely to make such extensions possible. One option I’m considering is exposing a parser setter so users can plug in their own parser implementation. This would allow CBOR, BSON, or other serialization formats to be supported without adding each one directly to the core library.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.