DEV Community

Serjio
Serjio

Posted on

MessageFrame vs Protobuf vs JSON: Does a Schema-Free C++17 Framework Actually Win?

MessageFrame vs Protobuf benchmark

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, no protoc, no rebuild.
  • Hybrid storage container. Up to 128 parameters (SMALL_CAPACITY) everything lives in a flat, allocation-free std::vector with excellent cache behavior. Past that it migrates transparently to a tsl::robin_map. You can skip the migration with a FrameConfig::initial_reserve sizing 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:

  • N parameters 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;
}
Enter fullscreen mode Exit fullscreen mode

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. An Arena would 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::vector instead 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::Arena plumbing 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
Enter fullscreen mode Exit fullscreen mode

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)