Protobuf Was 14× Faster Than JSON. Then I Fed It Text.
A tweet asked why we still ship JSON everywhere when Protobuf runs faster and packs smaller. I measured it. The answer depends on whether your payload is numbers or text.
Executive summary
A throwaway tweet sent me down a benchmark rabbit hole: why are we still using JSON for everything when gRPC and Protobuf are faster and smaller? I ran 81 BenchmarkDotNet cases across three payload shapes (number-heavy, string-heavy, and a nested tree), comparing System.Text.Json, MessagePack, and protobuf-net. On number-heavy data the folklore holds: binary formats serialized up to 14× faster and landed at roughly a quarter of the size. Feed the same code a string-heavy Article payload (title, author, body, tags) and the three formats converge. At 10,000 objects Protobuf serialized at 0.97× of JSON, a tie, and came out 9% smaller. The binary formats also triggered more GC collections than JSON while allocating fewer total bytes, which I did not expect. My takeaway: stay on JSON until you profile a real bottleneck, because most apps move text, where binary barely helps.
The challenge
It started, as these things do, with a confident take on the internet. The tweet ran something like:
Why is everyone still serializing JSON over HTTP in 2026? gRPC + Protobuf is faster and smaller. Just switch.
My first reaction: yeah, probably? I’d repeated “Protobuf is faster and smaller” in enough design reviews to treat it as a load-bearing fact. I had never measured it on my own machine with my own payloads. I’d inherited the belief and kept repeating it. You notice that about a claim and it sits wrong.
I narrowed the question to how much faster, on the data my services move, at the volumes they move it. A 14× win on a microbenchmark buys you nothing if the real delta is 3% on a payload that takes 200µs either way.
Going in, I expected the binary advantage to be real and narrow, and I expected the average CRUD service to leave it on the table for lack of the traffic and data shape that make it show up. That’s roughly where I landed. The road there had two surprises.
The naive approach breaks first
My first instinct was wrong, and it’s worth showing because it’s the same instinct most “I benchmarked it” posts ship with.
First attempt: a Stopwatch in a loop
The obvious move: Stopwatch.StartNew(), serialize N times in a loop, divide. I wrote that. The numbers came out garbage, and garbage in a useful way. The first call into each serializer runs far slower than the rest, because that’s when you pay JIT compilation plus one-time metadata caching: System.Text.Json building its JsonTypeInfo, MessagePack resolving and JIT-ing its generated formatters. That cold-start cost sits one to two orders of magnitude above the steady-state hot path.
A Stopwatch in a loop blends those two regimes. Measure a handful of iterations and cold-start dominates, so you time the JIT. Measure a million and you drown it out, but by luck rather than design. Either way you can’t say what you measured.
Assumption unlearned: “binary is always smaller and faster”
The second thing I dropped was the unconditional version of the claim. “Smaller and faster” holds the way “driving beats walking” holds, right up until you’re going one block in gridlock. The honest answer depends on the shape of your data, and you only see that if you vary the shape on purpose instead of benchmarking one convenient struct and generalizing from it.
I dropped the Stopwatch and picked up the boring, correct tool.
Architecture and methodology
I used BenchmarkDotNet v0.14.0 with [MemoryDiagnoser]. It handles warmup so cold-start stays out of the measured window, runs multiple launches, computes variance, and reports allocations and GC collections per generation. That’s the line between “I timed it” and “I measured it.”
The setup:
- Runtime: .NET 8.0.24, X64 RyuJIT AVX2, 13th Gen Intel Core i7-1355U.
- Contenders: System.Text.Json (the baseline, ratio 1.00), MessagePack-CSharp (attributed
[Key]mode), protobuf-net. - Operations: serialize, deserialize, round-trip.
- Sizes: N = 1, 100, and 10,000 objects, so I could watch the gap move with volume.
I tested three payload shapes, since shape is the variable that “Protobuf is faster” leaves unstated:
- Number-heavy (
Sensor): device IDs, timestamps, and a pile ofdoubleandlongreadings. Telemetry, the binary formats’ home turf. - String-heavy (
Article): title, author, a multi-paragraph body, a list of tags. What a CMS or a comment system moves all day. - Nested tree (
OrgNode): a recursive org chart, to stress framing overhead instead of leaf values.
The full run took about 47.5 minutes across 81 cases, exit 0, seeded data so you can reproduce it.
Code
The models are ordinary POCOs, annotated for each binary format. Trimmed for the article, but this is the shape of what ran:
// Number-heavy: the binary formats' best case
[MessagePackObject]
[ProtoContract]
public class Sensor
{
[Key(0)] [ProtoMember(1)] public long DeviceId { get; set; }
[Key(1)] [ProtoMember(2)] public long Timestamp { get; set; }
[Key(2)] [ProtoMember(3)] public double Temp { get; set; }
[Key(3)] [ProtoMember(4)] public double Humidity { get; set; }
[Key(4)] [ProtoMember(5)] public double Pressure { get; set; }
}
// String-heavy: where the three formats converge
[MessagePackObject]
[ProtoContract]
public class Article
{
[Key(0)] [ProtoMember(1)] public string Title { get; set; } = "";
[Key(1)] [ProtoMember(2)] public string Author { get; set; } = "";
[Key(2)] [ProtoMember(3)] public string Body { get; set; } = ""; // multi-paragraph
[Key(3)] [ProtoMember(4)] public List<string> Tags { get; set; } = new();
}
The benchmark methods stay thin. JSON carries the Baseline flag, so every other row reports as a ratio against it:
[Benchmark(Baseline = true), BenchmarkCategory("Serialize")]
public byte[] Json_Serialize() =>
JsonSerializer.SerializeToUtf8Bytes(_payload);
[Benchmark, BenchmarkCategory("Serialize")]
public byte[] MsgPack_Serialize() =>
MessagePackSerializer.Serialize(_payload);
[Benchmark, BenchmarkCategory("Serialize")]
public byte[] Proto_Serialize()
{
using var ms = new MemoryStream();
ProtoBuf.Serializer.Serialize(ms, _payload);
return ms.ToArray(); // note: buffers the whole array before returning
}
That last comment matters more than it looks.
Performance and metrics
Payload size: binary wins big on numbers, barely on text
| Shape | N | JSON | MsgPack | Proto | MsgPack / JSON | Proto / JSON |
|---|---|---|---|---|---|---|
| Number-heavy | 1 | 295 B | 74 B | 80 B | 25.1% | 27.1% |
| Number-heavy | 10,000 | 2.99 MB | 759 KB | 840 KB | 25.3% | 28.0% |
| String-heavy | 1 | 1,559 B | 1,441 B | 1,448 B | 92.4% | 92.9% |
| String-heavy | 10,000 | 11.84 MB | 10.65 MB | 10.75 MB | 90.0% | 90.8% |
| Nested tree | 10,000 | 41.63 MB | 20.24 MB | 21.23 MB | 48.6% | 51.0% |
Read the number-heavy rows. Binary takes a 295-byte JSON object down to about 74 bytes, a 4× cut, because a double that JSON writes as the 18-character string "1013.25178451203" ships as 8 raw bytes. Now read the string-heavy rows. JSON and the two binary formats land within 10% of each other, since a UTF-8 string is a UTF-8 string in all three. Once the wire is mostly your text, the punctuation JSON adds (quotes, commas, field names) rounds to nothing against the payload. None of these formats compress your prose. They only drop the punctuation.
Serialize speed: the 14× win, and the tie
| Shape | Format | Time vs JSON | Absolute |
|---|---|---|---|
| Number-heavy | MessagePack | 0.07× | 0.95 ms |
| Number-heavy | Protobuf | 0.08× | 1.09 ms |
| String-heavy | MessagePack | 0.69× | 9.95 ms |
| String-heavy | Protobuf | 0.97× | 13.96 ms |
| Nested tree | MessagePack | 0.45× | 70.2 ms |
| Nested tree | Protobuf | 0.81× | 125.9 ms |
The number-heavy rows are the tweet’s whole case: 0.07× means a ~14× speedup, and it holds up. JSON burns most of its time formatting numbers into decimal text while the binary formats copy bytes.
The string-heavy rows are the finding I came for. Protobuf serializes string-heavy data at 0.97× of JSON, a statistical tie, and MessagePack keeps a 0.69× edge. “Protobuf is faster” means “Protobuf is faster at numbers.” Once text dominates the payload, which is to say once it looks like most web traffic, you stop seeing the advantage you were promised.
The nested tree splits the win. Reporting within-shape ratios against JSON, MessagePack led on serialize at 0.45×, and Protobuf led on deserialize at 0.47× against MessagePack’s 0.63×. No single binary format took every axis.
Deserialize: same story, gentler slope
On read-back at 10,000 objects, number-heavy MessagePack hit 0.07×, the same ~14×. String-heavy deserialize landed at 0.65× for MessagePack and 0.62× for Protobuf, a real but unremarkable ~1.5× that you won’t feel on a request that spends most of its life waiting on a database.
Production and operational considerations
Two things in the data changed how I’d deploy this.
Binary can trigger more GC than JSON
On number-heavy serialize at 10,000 objects, MessagePack allocated 759 KB against JSON’s 2.99 MB, a 4× win on total bytes, and it still triggered 135.7 Gen0/Gen1/Gen2 collections per 1,000 ops against JSON’s 93.75. That’s more garbage-collection cycles from the format that allocated less.
The buffering in the Proto method explains it. JsonSerializer.SerializeToUtf8Bytes streams its output, while MessagePack and protobuf-net buffer the whole result before handing back a byte[], and those larger short-lived buffers push more objects through the generational collector even though the total allocated is smaller. “Binary means less GC pressure” is not a safe assumption. Total allocation favors binary; collection count sometimes doesn’t. If you tune for GC pause behavior, measure it rather than assume it.
The debuggability tax is daily; the speedup only helps under load
You can curl JSON and read it in a stack trace. Binary gives you none of that, and the cost lands on whoever debugs the system. You see the speed benefit only when you serialize a lot of numbers hot. Most services don’t. You pay the binary cost on every debugging session and collect the benefit only under heavy numeric load.
Cold-start, again, in production clothes
The cold-start penalty I designed out of the benchmark is one you may have designed into production. Serverless and scale-to-zero functions can pay that JIT-and-metadata cost on real requests. None of these steady-state numbers weight for that, and for some deployments it’s the number that matters most.
Trade-offs, lessons, and when I’d still pick JSON
What surprised me:
- The string tie. I expected binary to win everywhere by less than advertised. I did not expect a dead heat on a realistic content payload. That one cut against the claim I’d been repeating.
- The GC inversion. MessagePack allocated less but collected more, so “allocated” and “GC pressure” are different metrics.
- No single binary winner on the nested shape, where the right pick flips between serialize and deserialize, which is a small argument against optimizing before you measure.
Stay on JSON, mostly. It’s the right default until profiling shows serialization is your bottleneck, and for CRUD and web traffic made of text at modest sizes, it won’t be. The tweet was right about the physics. Most apps don’t run the traffic volume or carry the numeric shape where binary pays off.
I’d reach for binary on hot, high-volume, numeric internal paths where the 14× lands: telemetry, metrics pipelines, sensor and tick streams, large numeric lists, service-to-service chatter measured in millions of ops. If I went binary, I’d start with MessagePack, which gave near-Protobuf numbers on plain POCOs with far less ceremony. For the string-heavy payloads most apps push around, switching formats buys you about 10% and a debuggability tax. Skip it until you profile and find a reason not to.
Limitations (read these before you quote me)
- One machine, one runtime (.NET 8.0.24), one set of seeded synthetic payloads. A directional finding, not a law.
- Steady-state only. No cold-start or serverless weighting.
- MessagePack ran in attributed
[Key]mode. Contractless mode runs slower and larger. - Single-threaded. No concurrency, no streaming straight to a socket. I’d expect the buffering to behave differently there, but I didn’t test it.
Run it on your own payloads
The most useful thing you can do with this is stop trusting it and run it on your own payloads. Your data shape is the whole variable, and mine isn’t yours. The data is seeded, it runs under dotnet run -c Release, and the repo is up so you can point it at your real DTOs. I’d like to see numbers from a contractless MessagePack setup, or a concurrent streaming workload, since both are blind spots here.
References and further reading
- SerializationBenchmark: the full source for this run, with seeded data, so you can clone it and benchmark your own payloads.
- BenchmarkDotNet: a defensible way to time .NET code.
- System.Text.Json: how
JsonTypeInfosource generation changes the cold-start story. - MessagePack-CSharp: attributed vs contractless modes.
- protobuf-net.
- .NET GC fundamentals: generational collection, and why allocation count and collection count differ.