Protocol Buffers Performance: Benchmarks and Optimization

A rigorous examination of Protocol Buffers serialization performance, including benchmarks against JSON and other formats, memory allocation analysis, and practical optimization techniques for high-throughput systems.

technical8 min readBy Klivvr Engineering
Share:

Performance is often cited as a primary reason for choosing Protocol Buffers over JSON or other serialization formats. The claim is broadly true, but the details matter. How much faster is protobuf, under what conditions, and what can teams do to maximize the performance benefits? These questions deserve precise answers grounded in measurement rather than folklore.

This article presents benchmarks from the Nebula platform's internal testing, examines the underlying mechanisms that make protobuf fast, and provides concrete optimization techniques for teams building high-throughput services on top of the Nebula schema registry.

How Protobuf Encoding Works

Understanding protobuf's performance requires understanding its encoding. Every field is encoded as a (field_number, wire_type) tag followed by the field value. The tag itself is a varint, so small field numbers (1-15) encode in a single byte. The wire type determines how the subsequent bytes are interpreted.

There are six wire types:

Wire Type 0: Varint (int32, int64, uint32, uint64, sint32, sint64, bool, enum)
Wire Type 1: 64-bit (fixed64, sfixed64, double)
Wire Type 2: Length-delimited (string, bytes, embedded messages, repeated fields)
Wire Type 3: Start group (deprecated)
Wire Type 4: End group (deprecated)
Wire Type 5: 32-bit (fixed32, sfixed32, float)

Varints are the key to protobuf's compact encoding. A varint uses the most significant bit of each byte as a continuation flag: if set, more bytes follow. Small positive integers encode in fewer bytes:

Value 1:    0x01          (1 byte)
Value 127:  0x7F          (1 byte)
Value 128:  0x80 0x01     (2 bytes)
Value 300:  0xAC 0x02     (2 bytes)
Value 16383: 0xFF 0x7F    (2 bytes)

This means that common values (small IDs, boolean-like integers, short enums) are extremely compact. A message with ten small integer fields might occupy 20-30 bytes in protobuf versus 200-300 bytes in JSON, where every field name and value is encoded as ASCII text.

Strings and bytes are preceded by their length as a varint. Embedded messages are encoded the same way: the message's serialized bytes are treated as a length-delimited blob. This recursive structure means that protobuf can encode arbitrarily nested messages without any structural overhead beyond the length prefix.

Benchmark Methodology

The Nebula team benchmarks serialization performance using realistic message structures drawn from production schemas. The test messages include a range of field types and nesting depths:

message TransactionRecord {
  string transaction_id = 1;
  string account_id = 2;
  TransactionType type = 3;
  Money amount = 4;
  Money fee = 5;
  google.protobuf.Timestamp created_at = 6;
  google.protobuf.Timestamp settled_at = 7;
  TransactionStatus status = 8;
  string description = 9;
  map<string, string> metadata = 10;
  repeated TransactionEvent events = 11;
}
 
message TransactionEvent {
  string event_id = 1;
  string event_type = 2;
  google.protobuf.Timestamp occurred_at = 3;
  string actor = 4;
  string detail = 5;
}

Each benchmark serializes and deserializes 100,000 instances of this message, populated with realistic data (UUID strings, typical monetary amounts, 3-5 events per transaction). The measurements include wall-clock time, memory allocations, and serialized payload size.

All benchmarks were run on a dedicated test machine (AMD EPYC 7763, 64 cores, 128 GB RAM) running Go 1.22, with results averaged over 10 runs.

Serialization Speed

The results for serializing a single TransactionRecord to bytes:

Format              Avg Latency    Throughput (msg/sec)
-----------------------------------------------------------
Protobuf (binary)   890 ns         1,123,596
Protobuf (vtproto)  410 ns         2,439,024
JSON (std lib)      3,200 ns       312,500
JSON (jsoniter)     1,800 ns       555,556
MessagePack         1,500 ns       666,667

Standard protobuf serialization in Go is approximately 3.6 times faster than the standard library's JSON encoder. Using vtprotobuf, a code-generated optimized serializer for Go, the gap widens to 7.8 times faster.

Deserialization shows a similar pattern:

Format              Avg Latency    Throughput (msg/sec)
-----------------------------------------------------------
Protobuf (binary)   1,100 ns       909,091
Protobuf (vtproto)  520 ns         1,923,077
JSON (std lib)      5,800 ns       172,414
JSON (jsoniter)     2,600 ns       384,615
MessagePack         2,100 ns       476,190

JSON deserialization is particularly slow because the parser must handle arbitrary whitespace, escaped characters, and dynamic field name lookup. Protobuf's deserialization is a single pass through the byte stream, reading tags and dispatching to field-specific decoders without any string matching.

Payload Size

Serialized payload size affects network bandwidth, storage costs, and deserialization speed (fewer bytes to parse means less work).

Format              Avg Payload Size    Relative
---------------------------------------------------
Protobuf (binary)   284 bytes           1.0x
MessagePack         412 bytes           1.45x
JSON (compact)      687 bytes           2.42x
JSON (pretty)       1,043 bytes         3.67x

Protobuf achieves its compact size through three mechanisms: varint encoding for integers and field tags, no field name repetition (field numbers instead of names), and no structural characters (no braces, brackets, colons, or commas). The savings are most dramatic for messages with many small integer fields and least dramatic for messages dominated by long strings (which are encoded at near-parity in all formats).

Memory Allocation Analysis

In garbage-collected languages like Go and Java, memory allocation frequency directly impacts application performance through GC pressure. Protobuf's generated code is designed to minimize allocations.

func BenchmarkProtoMarshal(b *testing.B) {
    msg := createTestTransaction()
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        data, err := proto.Marshal(msg)
        if err != nil {
            b.Fatal(err)
        }
        _ = data
    }
}
 
// Results:
// BenchmarkProtoMarshal-64     1123596    890 ns/op    384 B/op    1 allocs/op
// BenchmarkJSONMarshal-64       312500   3200 ns/op   1856 B/op   24 allocs/op

Standard protobuf marshaling produces a single allocation (the output byte slice). JSON marshaling produces 24 allocations due to intermediate string building, reflection-based field traversal, and temporary buffer management.

For even lower allocation overhead, the vtprotobuf library provides MarshalToSizedBuffer and MarshalToVT methods that can marshal into a pre-allocated buffer:

func BenchmarkVTProtoMarshalReuse(b *testing.B) {
    msg := createTestTransaction()
    buf := make([]byte, msg.SizeVT())
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        n, err := msg.MarshalToSizedBufferVT(buf)
        if err != nil {
            b.Fatal(err)
        }
        _ = buf[:n]
    }
}
 
// Results:
// BenchmarkVTProtoMarshalReuse-64    2439024    410 ns/op    0 B/op    0 allocs/op

Zero allocations per serialization. In a service processing millions of messages per second, this reduction in GC pressure translates to lower tail latency and more predictable performance.

Optimization Techniques

Beyond choosing the right serialization library, several schema-level and application-level optimizations can improve protobuf performance.

Use field numbers 1-15 for hot fields. Fields numbered 1-15 encode their tag in a single byte. Fields 16 and above require two bytes. For messages that are serialized millions of times, the cumulative savings are meaningful. Place the most frequently set fields in the 1-15 range.

message HighFrequencyEvent {
  // Hot fields: numbers 1-15 (single-byte tag)
  string event_id = 1;
  int64 timestamp_ms = 2;
  EventType type = 3;
  int32 source_id = 4;
 
  // Cold fields: numbers 16+ (two-byte tag)
  string description = 16;
  map<string, string> metadata = 17;
  bytes payload = 18;
}

Prefer packed repeated fields. In proto3, repeated scalar fields are packed by default: instead of encoding each element with its own tag, all elements are packed into a single length-delimited blob. This is significantly more compact for arrays of integers or floats.

message TimeSeries {
  repeated int64 timestamps = 1;  // packed by default in proto3
  repeated double values = 2;     // packed by default in proto3
}

A time series with 1000 data points might be 8 KB in packed protobuf versus 20 KB in JSON.

Avoid deeply nested messages when possible. Each level of nesting adds a length prefix and requires a separate allocation during deserialization. Flattening shallow hierarchies can reduce both payload size and deserialization cost, though this must be balanced against schema clarity.

Use bytes instead of repeated small messages for bulk data. If you are transmitting a large collection of fixed-size records (such as a batch of sensor readings), encoding them as a single bytes field with a custom layout can be faster than a repeated message field, at the cost of losing schema self-description.

Pool message objects in hot paths. In Go, use sync.Pool to reuse message objects and their underlying allocations:

var transactionPool = sync.Pool{
    New: func() interface{} {
        return &pb.TransactionRecord{}
    },
}
 
func processMessage(data []byte) error {
    msg := transactionPool.Get().(*pb.TransactionRecord)
    defer func() {
        msg.Reset()
        transactionPool.Put(msg)
    }()
 
    if err := proto.Unmarshal(data, msg); err != nil {
        return err
    }
    // process msg...
    return nil
}

This pattern eliminates the allocation for the message object itself and, in some implementations, reuses internal buffers as well.

When Performance Does Not Matter

It is worth acknowledging that many services will never notice the performance difference between protobuf and JSON. A REST API that handles 100 requests per second with 50ms of database latency per request will not be measurably improved by switching to protobuf serialization. The serialization overhead is a rounding error compared to I/O.

Performance optimization should be guided by profiling, not assumptions. Measure your actual bottlenecks before investing in serialization optimization. Protobuf's performance is a welcome benefit, but it is rarely the sole reason to adopt it. Schema evolution, type safety, and code generation are often more valuable in practice.

Conclusion

Protocol Buffers deliver substantial performance advantages over JSON and most other serialization formats: 3-8 times faster serialization, 2-4 times smaller payloads, and dramatically fewer memory allocations. These advantages are real and measurable, particularly in high-throughput services on the Nebula platform. By understanding the encoding mechanics, choosing the right tooling (such as vtprotobuf for Go), and applying schema-level optimizations, teams can extract the maximum performance from their protobuf-based contracts. At the same time, performance should not be the only consideration. The strongest argument for protobuf in the Nebula schema registry is the combination of performance, type safety, and schema evolution, a package that no other serialization format matches.

Related Articles

technical

Building a Schema Registry: Patterns and Best Practices

A comprehensive guide to building and operating a Protocol Buffers schema registry, covering architecture patterns, governance models, tooling integration, and the operational practices that keep a registry healthy as it scales.

9 min read
business

Using Protocol Buffers Across a Microservices Architecture

A business and architecture-focused guide to adopting Protocol Buffers as the standard contract language across a microservices ecosystem, covering shared types, dependency management, team workflows, and the role of a centralized schema registry.

10 min read
business

API Versioning Strategies with Protocol Buffers

A business-oriented guide to API versioning with Protocol Buffers, covering when and how to version, migration strategies, multi-version support, and the organizational processes that make versioning sustainable.

9 min read