Every serialization library claims to be fast. Benchmarking your own library against itself is easy and mostly useless. The useful test is different: take the same logical message, build it with four different libraries, and measure the full round-trip on the same hardware.
That is what this post does. MessageFrame — the schema-free C++17 messaging library I built for a multi-SDR telemetry system — goes up against Google Protobuf, nlohmann/json, and msgpack-cxx. The harness is in the repository, so you can reproduce every number below in a couple of minutes.
Quick recap: what MessageFrame is
If you have not seen the earlier posts (short introduction), here is the idea in three bullets:
-
Two-part keys, no schema, no code generation. Every parameter is addressed as
(device, parameter)and assembled at runtime:msg.add("sdr_1", "rx_gain", VALUE(30.0)). A new device appears on the network, you just start adding its parameters. No.proto, noprotoc, no rebuild. -
Hybrid storage container. Up to 128 parameters (
SMALL_CAPACITY) everything lives in a flat, allocation-freestd::vectorwith excellent cache behavior. Past that it migrates transparently to atsl::robin_map. You can skip the migration with aFrameConfig::initial_reservesizing hint. -
Fixed 36-byte header + isolated binary attachments. A router can read message ID, source, target, and flags without touching the payload. Raw IQ samples or spectrum snapshots travel as attachments via
std::move, not through the key/value store.
The honest secret: the wire format is MessagePack
Let me get the elephant in the room out of the way: MessageFrame does not invent a wire format. The payload is a flat MessagePack map, and the two-part keys are joined into a single string with the ASCII Unit Separator byte (0x1F). Any MessagePack-compatible parser can decode the body.
That decision has one very useful side effect for this benchmark: msgpack-cxx is the same wire format without the framework. So the msgpack column is not a competitor, it is a baseline floor. It shows what the raw format costs when you bring your own value model, your own header, your own key handling, and your own container. MessageFrame is what you pay to not write those yourself.
This also means the benchmark is the fair version of "MessagePack with a framework layer on top", not a cherry-picked "my format is fastest" exercise.
The benchmark: same message, four backends
All four backends receive the identical logical message:
-
Nparameters with mixed types (double,int64,bool,string), cycled deterministically so wire sizes stay constant between runs. - Keys are two-part (
device,parameter) in MessageFrame and flat"d0.p0"strings elsewhere. - One scenario adds a 1 MiB binary attachment, standing in for an IQ capture.
- A full per-message cycle is timed: parameter addition -> serialization -> deserialization, min of 3 passes, single thread.
The protobuf side uses the closest structural equivalent of MessageFrame's model:
message TelemetryValue {
oneof v { double d = 1; int64 i = 2; bool b = 3; string s = 4; }
}
message TelemetryFrame {
map<string, TelemetryValue> params = 1;
bytes attachment = 2;
}
Build: repository Release defaults — -O3 -march=native plus LTO — on an Intel Core 7 240H (Ubuntu 22.04, GCC).
Results: time per message (µs)
| Scenario | MessageFrame | MF + hint | protobuf | nlohmann/json | msgpack-cxx* |
|---|---|---|---|---|---|
| 4 parameters | 0.59 | — | 0.58 | 1.64 | 0.28 |
| 32 parameters | 3.04 | — | 4.76 | 10.56 | 1.04 |
| 127 parameters (vector mode) | 11.28 | — | 19.52 | 47.09 | 3.57 |
| 150 parameters (hash-map mode) | 24.01 | 19.85 | 22.76 | 64.94 | 4.18 |
| 1,024 parameters | 196.50 | 157.46 | 303.52 | 471.40 | 27.22 |
| 4 params + 1 MiB attachment | 189.76 | — | 276.07 | 14,159 | 138.86 |
* msgpack-cxx has no in-memory document, header, or two-part keys — it is the wire-format floor, not an equivalent API.
Where the time goes (add / serialize / deserialize, µs):
| Scenario | Backend | add | serialize | deserialize |
|---|---|---|---|---|
| 4 params | MessageFrame | 0.13 | 0.17 | 0.29 |
| 4 params | protobuf | 0.12 | 0.16 | 0.30 |
| 4 params | nlohmann/json | 0.22 | 0.36 | 1.06 |
| 1,024 params | MessageFrame | 82.80 | 36.96 | 76.07 |
| 1,024 params | MessageFrame + hint | 52.78 | 35.03 | 70.54 |
| 1,024 params | protobuf | 94.02 | 59.39 | 152.70 |
| 1,024 params | nlohmann/json | 145.32 | 62.08 | 264.21 |
| 1 MiB | MessageFrame | 25.93 | 44.74 | 116.04 |
| 1 MiB | protobuf | 116.12 | 113.90 | 46.06 |
| 1 MiB | nlohmann/json | 1,457 | 3,725 | 8,977 |
And the wire sizes (bytes):
| Scenario | MessageFrame | protobuf | nlohmann/json | msgpack-cxx |
|---|---|---|---|---|
| 4 params | 73 | 66 | 68 | 53 |
| 127 params | 1,816 | 2,237 | 1,986 | 1,550 |
| 1,024 params | 15,297 | 18,858 | 16,795 | 13,237 |
| 1 MiB attachment | 1,048,662 | 1,048,646 | 1,398,188 | 1,048,645 |
What the numbers actually say
MessageFrame beats protobuf by 1.5-2x on large frames. At 1,024 parameters the full round-trip is 196 µs vs 303 µs, and the gap is mostly in deserialize (76 vs 153 µs). At 32 and 127 parameters it wins clearly too.
At 4 and 150 parameters the two are statistically tied. The 150-parameter row is the honest one to explain: that is exactly where MessageFrame crosses the vector-to-map threshold and pays the one-time migration of 128 entries. The FrameConfig::initial_reserve hint skips it and drops the round-trip from 24.0 to 19.9 µs. Without the hint it is a tie; with the hint it wins.
nlohmann/json loses everywhere, and loses badly with binary data. 1.64 µs vs 0.59 µs on the smallest frame, and 14.2 milliseconds vs 190 µs with the 1 MiB attachment — roughly 75x slower — because the attachment is base64-encoded twice (in and out). That is the real price of a text format for binary payloads, not a flaw in the library. But the practical consequence matters more: a 14 ms stall per frame is a blocker for SDR and real-time workloads, where the whole point is streaming IQ samples at a fixed cadence.
msgpack-cxx is faster, and that is fine. At 1,024 parameters raw MessagePack is 27 µs vs MessageFrame's 196 µs. That gap is the entire framework: the typed value model, the header, the two-part keys, the hybrid container, the zero-allocation path. If you are happy building all of that yourself, use raw msgpack. Most people are not.
A few fairness notes, because benchmarks without them are marketing:
-
protobuf uses the standard heap API with no
Arena. AnArenawould shave some of its allocation overhead on large messages. -
MessageFrame copies attachments on deserialize (116 µs vs protobuf's 46 µs in the 1 MiB row). This is the documented zero-copy trade-off: the library builds a fresh
std::vectorinstead of aliasing the input buffer. - GCC LTO (our repo Release default) slows raw msgpack-cxx's packer by about 2x (a codegen quirk we reproduced); MessageFrame, protobuf, and nlohmann/json are unaffected by the setting.
When to choose what
- MessageFrame — you need runtime-defined messages, dynamic device lineups, no code generation, and a hard C++17 constraint. SDR telemetry, IoT gateways, robotics config dumps, IPC between embedded nodes.
-
Protobuf — your schemas are set in stone, you work across many teams and languages, and you want the full ecosystem (validators, reflection, mature tooling). The codegen tax is fine when the schema never changes. Note the allocation story too: MessageFrame gives zero-allocation insertion up to 128 parameters out of the box (flat vector + SSO for keys), while Protobuf only gets there if you write manual
google::protobuf::Arenaplumbing yourself. - JSON — you need to debug by eyeballing packets and bandwidth/CPU budget is generous. Not a candidate for real-time binary-heavy traffic: the ~75x slowdown on binary payloads makes it a blocker for SDR and real-time tasks.
Try it yourself
Every number in this post is reproducible from the repository:
git clone --recursive https://github.com/stubcpp/MessageFrame.git
cd MessageFrame
cmake -B build -DCMAKE_BUILD_TYPE=Release -DMSGFRAME_BUILD_CROSS_BENCHMARK=ON
cmake --build build -j
./build/mf_crossbench
The harness lives in benchmarks/cross_format — one shared scenario definition, one backend file per format, and a single main.cpp running min-of-3 passes. If your numbers differ from mine, that is exactly the point of publishing the harness: it is a conversation starter, not a slogan.
Closing
MessageFrame is not a universal replacement for Protobuf, and it is not faster than raw MessagePack. What it does is close the gap between the two: the ergonomics of dynamic messaging — no schemas, no codegen, typed access, zero-allocation hot paths — at roughly a fifth of raw MessagePack's cost and consistently faster than Protobuf and JSON on the workloads that matter for telemetry.
The full methodology, per-phase timings, and caveats are in docs/performance.md in the repo. If you run the harness and get different numbers, or if you want a backend for your favorite format added, open an issue or a PR.
And if this post made you want to try it — the project is at MessageFrame. A star helps other developers find it.

Top comments (0)