A Go service at a high-traffic company began experiencing mysterious CPU spikes. The flamegraphs revealed something unexpected: 30-40% of CPU time was spent inside json.Marshal and json.Unmarshal. No database queries were slow. No algorithms were inefficient. The serialization layer alone was consuming nearly half the computational budget.
This isn’t an anomaly. At scale, the choice of serialization format becomes a first-order performance concern. The difference between JSON and binary formats isn’t a few percentage points—it’s often 5-7x in throughput and 2-3x in payload size.
The Tax We Pay for Readability
JSON’s ubiquity stems from one design decision: human readability. Every field name is stored as text. Every number becomes a string of ASCII digits. Every structure is wrapped in quotes and brackets.
Consider encoding the number 150:
JSON representation:
{"id":150}
This consumes 9 bytes. The characters i, d, :, 1, 5, 0 and structural markers all require individual bytes.
Protocol Buffers wire format:
08 96 01
Three bytes total. The 08 encodes both the field number (1) and the wire type (varint). The 96 01 encodes 150 using variable-length integer encoding.
The disparity grows with scale. A benchmark using a real Go struct (approximately 500 bytes in JSON) showed:
| Format | Encode (ns) | Decode (ns) | Size (bytes) |
|---|---|---|---|
| JSON | 42,000 | 68,000 | 500 |
| MessagePack | 12,000 | 19,000 | 295 |
| Protobuf | 6,500 | 9,000 | 190 |
Source: Real-world Go benchmarks from high-throughput systems
The numbers tell a story: binary formats achieve 3-6x faster encoding and 3-7x faster decoding while producing payloads 40-60% smaller.
Why Text Parsing Burns CPU Cycles
The performance gap isn’t magic. It’s predictable from first principles.
Reflection Overhead
Go’s standard encoding/json package uses reflection at runtime. For every field, the serializer must:
- Look up the field name in struct metadata
- Check the field type
- Determine the appropriate encoding method
- Allocate memory for the string representation
This reflection cost compounds. A struct with 20 fields triggers 20 separate reflection calls. Protocol Buffers avoid this entirely by generating code at compile time. The proto compiler produces specialized encoding functions that write bytes directly, with no runtime type inspection.
String Allocation Chaos
Every JSON string literal requires memory allocation. When parsing {"name":"Alice"}, the parser allocates a new string for "name", another for "Alice", and additional strings for the structural characters during intermediate parsing states.
A 700MB JSON file parsed in Visual Studio consumed nearly 9GB of memory—a 10x overhead from string allocations alone. The garbage collector struggles under this pressure, triggering frequent collections that pause application threads.
Number Parsing: The Hidden Battlefield
Converting ASCII digits to binary numbers isn’t trivial. The string "1234567890.123456" requires:
- Detecting the decimal point position
- Processing each digit through multiplication and addition
- Handling floating-point precision edge cases
- Validating the number format
The simdjson library demonstrated that using SIMD (Single Instruction, Multiple Data) instructions could parse JSON at over 2GB per second on a single core—still slower than binary formats but far ahead of traditional parsers. The fact that such extreme optimization is necessary reveals how expensive text parsing fundamentally is.
Protocol Buffers store numbers in binary. The integer 1,234,567,890 occupies 4 bytes in fixed32 encoding. No parsing required—just a direct memory copy.
Varint: The Encoding That Saves Billions of Bytes
Protocol Buffers achieve compactness through variable-length integer encoding, or “varint.” The principle is simple: small numbers should use fewer bytes.
How varint works:
Each byte uses 7 bits for data and 1 bit (the most significant bit) as a continuation flag. If MSB = 1, more bytes follow. If MSB = 0, this is the final byte.
The number 1 encodes as a single byte: 00000001
The number 300 requires two bytes: 10101100 00000010
Decoding:
- Byte 1:
10101100→ MSB=1 (continue), data =0101100(44) - Byte 2:
00000010→ MSB=0 (final), data =0000010(2) - Reconstructed:
0000010 0101100= 256 + 44 = 300
This encoding proves remarkably efficient. At Google scale, where billions of messages traverse internal networks daily, varint encoding saves petabytes of bandwidth annually.
Schema Evolution: The Hidden Cost of Flexibility
JSON’s schema-less nature appears advantageous. Add a field? Just add it. Remove a field? No problem. But this flexibility creates hidden complexity.
When a producer adds a field, consumers must handle it gracefully. When a producer removes a field, consumers must tolerate its absence. Without explicit contracts, these changes cause silent failures—null pointer exceptions, missing data, corrupted state.
Protocol Buffers, Avro, and Thrift solve this through formal schema evolution rules:
Protocol Buffers:
- New fields must have fresh field numbers
- Field numbers cannot be reused
- Field names can change (they’re not encoded in the wire format)
- Removed fields become “reserved” and their numbers forever unusable
Avro:
- Fields matched by name, not position
- Fields can be reordered freely
- New fields must have default values
- Field renaming requires aliases during transition
Martin Kleppmann’s analysis revealed a crucial distinction: Protocol Buffers tag individual fields, while Avro tags entire records with schema versions. Avro’s approach requires maintaining a schema registry, but produces more compact encodings since field names and tags never appear in the wire format.
The Zero-Copy Frontier: FlatBuffers and Cap’n Proto
For latency-sensitive systems, even Protocol Buffers’ parsing overhead is too high. FlatBuffers and Cap’n Proto take a radical approach: skip parsing entirely.
These formats structure data so that the in-memory representation is identical to the on-wire representation. Deserialization becomes a simple pointer offset calculation—O(1) complexity instead of O(n).
FlatBuffer layout:
[Root Table] → [String A] → [String B] → [Array Data]
↓
Access via offsets, no parsing
The trade-off is complexity. Writing FlatBuffers requires building data “backwards”—you must know string lengths and array sizes before writing the structures that reference them. But for real-time game engines, financial trading systems, and mobile applications where every microsecond matters, zero-copy deserialization is invaluable.
Simple Binary Encoding (SBE), developed for the FIX trading protocol, takes this further with fixed-width fields. No variable-length encoding means no branching in the decoder—optimal for CPU pipelining and cache locality.
When JSON Actually Makes Sense
Binary formats aren’t universally superior. JSON retains advantages in specific contexts:
Public APIs: Third-party developers need to debug requests. Opening a response in a browser or curl output immediately reveals the structure. With Protocol Buffers, debugging requires proto files and specialized tools.
Configuration Files: Human-authored configuration benefits from readability. A typo in JSON produces a clear parse error. A typo in binary format produces corruption.
Small Payloads: For payloads under 1KB, the performance difference becomes negligible. The overhead of managing .proto files and generated code outweighs benefits.
Browser-First Applications: JSON is JavaScript’s native object literal format. Using Protocol Buffers in browsers requires adding a parsing library (like protobuf.js), which adds download size and initialization time.
LinkedIn’s migration to Protocol Buffers revealed a nuanced picture: for compressed responses, Protobuf was only 4% faster than JSON in JavaScript environments. But in Java-to-Java communication, Protobuf achieved 78% lower latency—nearly 5x faster for non-compressed payloads.
Security Implications: The Deserialization Attack Surface
Binary formats reduce certain attack vectors while introducing others.
JSON’s simplicity limits exploitation surface. There’s no type confusion, no object instantiation beyond basic types. But JSON parsing at scale enables denial-of-service attacks: deeply nested structures can cause stack overflows, and massive strings can exhaust memory.
Java serialization and similar frameworks suffer from “gadget chain” attacks—crafted payloads that instantiate arbitrary classes during deserialization. Protocol Buffers avoid this by design: they serialize data, not objects. There’s no mechanism to instantiate arbitrary classes.
Avro requires special attention to schema compatibility. A malicious producer could send a schema that causes excessive memory allocation on the consumer. Schema registries must validate schemas before acceptance.
The Decision Matrix
Choosing a serialization format requires evaluating constraints:
| Factor | JSON | MessagePack | Protobuf | FlatBuffers |
|---|---|---|---|---|
| Performance | Poor | Good | Excellent | Exceptional |
| Payload Size | Large | Medium | Small | Small |
| Human Readable | Yes | No | No | No |
| Schema Required | No | No | Yes | Yes |
| Debugging | Easy | Hard | Hard | Hard |
| Cross-Language | Native | Libraries | Generated | Generated |
| Schema Evolution | None | None | Good | Limited |
For high-throughput internal microservices, Protocol Buffers with gRPC provides the best combination of performance, type safety, and schema evolution. For public APIs, JSON remains pragmatic. For real-time systems with microsecond latency requirements, FlatBuffers or Cap’n Proto are worth the complexity.
The Engineering Reality
The 30% CPU time spent on JSON serialization isn’t a bug—it’s a consequence of choosing human readability over machine efficiency. This choice is valid for many applications. But at scale, the accumulated cost becomes material.
Consider a service processing 100,000 requests per second with 500-byte JSON payloads. Switching to Protocol Buffers could:
- Reduce CPU utilization by 40%
- Decrease bandwidth by 60%
- Lower p99 latency by 20-60%
These improvements compound. Lower CPU means fewer servers. Reduced bandwidth means lower cloud egress costs. Faster response times mean better user experience.
The JSON-versus-binary debate isn’t about finding a universal winner. It’s about understanding that serialization format is an architectural decision with measurable performance implications—implications that scale with traffic.
References
- Protocol Buffers Official Documentation - Encoding. https://protobuf.dev/programming-guides/encoding/
- Kleppmann, Martin. “Schema evolution in Avro, Protocol Buffers and Thrift.” December 2012. https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
- Langdale, Geoff and Lemire, Daniel. “Parsing Gigabytes of JSON per Second.” https://arxiv.org/html/2407.13494v2
- LinkedIn Engineering. “LinkedIn Integrates Protocol Buffers With Rest.li for Improved Performance.” April 2023. https://www.linkedin.com/blog/engineering/infrastructure/linkedin-integrates-protocol-buffers-with-rest-li-for-improved-m
- Krebs, Bruno. “Beating JSON performance with Protobuf.” Auth0 Blog, January 2017. https://auth0.com/blog/beating-json-performance-with-protobuf/
- Protocol Buffers History. https://protobuf.dev/history/
- Wikipedia. “Comparison of data-serialization formats.” https://en.wikipedia.org/wiki/Comparison_of_data-serialization_formats
- Confluent Documentation. “Schema Evolution and Compatibility.” https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
- Simple Binary Encoding (SBE) - FIX Trading Community. https://www.fixtrading.org/standards/sbe-online/
- simdjson GitHub Repository. https://github.com/simdjson/simdjson