Migrating legacy JSON APIs to gRPC frequently stumbles over a common anti-pattern: unstructured, dynamic JSON fields (such as metadata or extra_properties) mapped directly into Protobuf using google.protobuf.Value or google.protobuf.Struct.
These belong to Protobuf’s Well-Known Types (WKTs), a library of standardized, common message schemas defined by Google (such as Timestamp, Duration, and Any) that ship out-of-the-box with the Protobuf compiler to provide consistent representation for reusable data structures across different languages.
But what actually are these specific dynamic types under the hood, and how are they used?
The Value Message
At the core of dynamic Protobuf is google.protobuf.Value. If we look at its official definition, it is defined as a oneof containing the possible JSON-compatible data types:
message Value {
oneof kind {
NullValue null_value = 1;
double number_value = 2;
string string_value = 3;
bool bool_value = 4;
Struct struct_value = 5;
ListValue list_value = 6;
}
}
google.protobuf.Value represents a dynamically typed wrapper that can hold any single JSON-compatible value: null, a number (encoded as a double-precision float), a string, a boolean, a nested Struct (representing a JSON object), or a list of values (ListValue, representing a JSON array).
How Value is Used
Value is used to represent a single dynamic field whose specific type is not known at compile-time. Instead of declaring a rigid type like string or int32, defining a field as Value allows it to wrap any scalar, nested object, or list at runtime. In languages like Go, this compiles to a struct where the type of the value is inspected or retrieved via type assertions or helper getters on the underlying oneof interface.
The Struct Message
To represent a dynamic object structure (a collection of key-value pairs), Protobuf provides google.protobuf.Struct. Under the hood, it maps string keys to Value messages:
message Struct {
map<string, Value> fields = 1;
}
In practice, Struct behaves like a JSON object. In Go, this translates directly to a key-value map (map[string]any) or a raw JavaScript object where the keys are strings and the values are dynamically typed.
How Struct is Used
Struct is used to carry arbitrary structured dictionaries, and can be utilized in two primary ways:
- Directly: As a top-level field in your message to represent unstructured configuration or metadata (e.g.,
google.protobuf.Struct metadata = 1;). - Via
Value: SinceStructis one of the options insideValue’soneof kind(Struct struct_value = 5;), aStructcontains a map of keys toValues, which can themselves beStructs. This recursive relationship allowsStructandValueto together represent complex, deeply nested JSON objects containing nested lists and primitive values.
Together, they allow Protobuf messages to carry arbitrary, unstructured JSON-like payloads without declaring a strict schema beforehand. This is a common architectural pattern, and it successfully solves a real developer pain point: handling highly dynamic data. Since Protobuf is famous for being fast and compact, one would intuitively think that wrapping dynamic payloads in these well-known types would still be more efficient than standard JSON.
I decided to test that assumption, and the results were the complete opposite of what I expected. Most of Protobuf’s performance advantage comes from ahead-of-time schema knowledge. Statically compiled schemas are the primary optimization lever that allows Protocol Buffers to achieve speed and compactness. The moment you remove schema information and adopt unstructured types like google.protobuf.Value or google.protobuf.Struct, you give up many of the optimizations that make Protobuf fast and compact.
The Structural Cost of Dynamic Data
Before looking at the benchmark numbers, it helps to understand why google.protobuf.Value is structurally expensive on the wire compared to compact JSON. When representing arbitrary object structures, google.protobuf.Struct is defined under the hood as map<string, Value> fields = 1;.
In the Protobuf wire format, maps are not native primitives. Instead, they are represented as a repeated list of auto-generated key-value message entries. The map field is equivalent to:
message MapEntry {
string key = 1;
google.protobuf.Value value = 2;
}
// Inside google.protobuf.Struct:
repeated MapEntry fields = 1;
This explains why every key-value entry is serialized as a nested sub-message (MapEntry) containing two inner fields (key and value), each with its own tag and length overhead.
For tiny dynamic payloads, the structural metadata overhead of Struct can exceed the payload itself. While in practice payloads are enclosed in full objects and messages, comparing the raw serialization layout of a single dynamic key-value entry like "age": 30 illustrates this overhead:
Wire Format Layout Comparison
1. Compact JSON (10 bytes)
For this conceptual representation of JSON, containing a single key/value pair for age wrapped in enclosing object braces, we have:
{"age":30}
This payload is exactly 10 bytes in size:
- 2 bytes for the enclosing object braces
{} - 5 bytes for the key
"age"(including quotes) - 1 byte for the colon separator
: - 2 bytes for the numeric characters
"30"
2. Dynamic Protobuf (Protoscope Representation)
If we describe the dynamic Protobuf binary payload using Protoscope (the language for representing raw Protobuf wire formats) to visualize the structure and tags:
1: { # Struct.fields map entry header -> 2 Bytes
1: "age" # MapEntry.key (tag/len + "age" string) -> 5 Bytes
2: { # MapEntry.value Value wrapper header -> 2 Bytes
2: 30.0 # Value.number_value double field -> 9 Bytes
}
}- Each set of braces
{}represents a length-delimited sub-message, which compiles to a field tag followed by a length prefix byte on the wire. - The prefix tags
1:and2:represent the field numbers. 30.0compiles to field tag 2 (wire type 1, 64-bit) followed by its fixed 8-byte float value.
Summing these up (2B + 5B + 2B + 9B) results in a total payload size of exactly 18 bytes for this specific encoding.
So with this context, we can now say why google.protobuf.Value is inefficient:
Double Nesting and Header Overhead: In compact JSON, the valid payload
{"age":30}consumes 10 bytes total. In dynamic Protobuf, the nested schema-less structure requires 18 bytes. The nested header metadata alone (the field tags and length prefixes for each layer) consumes 7 bytes, which is nearly as large as the entire JSON payload before any key or value content is even written.No Field Name Compression: One of Protobuf’s largest size advantages usually comes from discarding human-readable field names (like
"age") and replacing them with compact, 1-byte numeric tags. However, becausegoogle.protobuf.Structis unstructured, it must serialize the actual field name string"age"on the wire. This completely forfeits the field-name compression benefit that makes static Protobuf so compact.No Varint Compression for Numbers: Instead of benefiting from Protobuf’s specialized integer encodings, all numeric values are represented through a double-precision float field. Small integers suffer most because Protobuf normally compresses them using varints. Large values may see less dramatic differences because JSON must serialize every digit as text. To visualize the wire layout comparison for representing the small number
30:
Compact JSON ("30"):
00110011 00110000 -> 2 bytes
Static Protobuf (Varint 30):
00011110 -> 1 byte
Dynamic Protobuf (30.0 double-precision float):
00000000 00000000 00000000 00000000
00000000 00000000 00111110 01000000 -> 8 bytes (little-endian)
As a result of this deeply nested structure and fixed-size floats, dynamic Protobuf payloads often end up larger on the wire than compact JSON. However, this result is heavily workload-dependent. The biggest penalties come from having many fields, many keys, small numeric values, and deeply nested structures. A payload consisting mostly of a single massive string will not exhibit the same structural overhead.
Wire Inefficiency vs. Runtime Inefficiency
Dynamic Protobuf hurts in two completely different ways that affect different engineering decisions:
- Wire Inefficiency: The serialized payload becomes larger than many developers expect. This is caused by human-readable field names being serialized repeatedly, nested map entry encoding, double-precision float storage for all numbers, and the loss of varint integer compression. Bandwidth-sensitive systems, databases, or event brokers care heavily about this.
- Runtime Inefficiency: The runtime representation in Go becomes allocation-heavy and expensive to parse. This is caused by Go’s allocation behavior, interface-heavy and pointer-heavy structures in the standard
structpbpackage, Go’s reflection model, and tree-shaped decoding. CPU-bound services that deserialize payloads frequently care heavily about this.
To see exactly how these wire and runtime inefficiencies compound, I built a benchmark suite to measure the real-world performance differences.
The Benchmark Setup
I built a Go benchmark comparing standard JSON against various Protobuf strategies across three payload sizes. The complete benchmark suite and Go test code are available in the sudorandom/kmcd.dev repository on GitHub.
The payload configurations are:
- Small: A flat object with 4 fields (string ID, status boolean, age integer, score float).
- Medium: A nested user signup event containing an actor object, string tags, and a metadata map.
- Large: An array repeating the Medium object 100 times.
This benchmark focuses specifically on the tradeoff between schema-less Protobuf WKTs and common Go JSON implementations rather than surveying all binary serialization formats (like MessagePack, CBOR, BSON, Avro, or FlatBuffers).
Benchmark Variants
To evaluate performance across different serialization models, I compared the following variants:
| Variant | Format | Description |
|---|---|---|
| Concrete (JSON) | JSON | Serializes a standard concrete Go struct using Go’s standard encoding/json library (JSON v1). |
| Concrete (JSONv2) | JSON | Serializes a standard concrete Go struct using the experimental, higher-performance github.com/go-json-experiment/json library (JSON v2). |
| Map (JSON) | JSON | Serializes a generic, schema-less Go map (map[string]any) using the standard encoding/json library. |
| Map (JSONv2) | JSON | Serializes a generic, schema-less Go map (map[string]any) using the experimental github.com/go-json-experiment/json library. |
| Concrete (proto) | Protobuf | Serializes statically generated Protobuf messages using Go’s official google.golang.org/protobuf/proto library. |
| Concrete (vtproto) | Protobuf | Serializes statically generated Protobuf messages using PlanetScale’s optimized, reflection-free vtproto generator. |
| google.protobuf.Any (proto) | Protobuf | Serializes static Protobuf messages wrapped in a dynamic, polymorphic Well-Known Type google.protobuf.Any. |
| google.protobuf.Value (proto) | Protobuf | Serializes schema-less dynamic payloads using the Well-Known Type google.protobuf.Value (structpb). |
| Protobuf + JSON | Protobuf | Bypasses the dynamic WKT wrapper by storing the raw serialized JSON string directly inside an opaque Protobuf string/bytes field (Opaque JSON Packaging). |
| Concrete (JSONProto) | JSON | Serializes statically generated Protobuf messages into JSON format using Go’s official protojson encoder. |
| google.protobuf.Value (JSONProto) | JSON | Serializes dynamic google.protobuf.Value payloads into JSON format using Go’s official protojson encoder. |
| google.protobuf.Any (JSONProto) | JSON | Serializes polymorphic google.protobuf.Any wrappers into JSON format using Go’s official protojson encoder. |
A Note on Any vs Value:
Any and Value solve fundamentally different problems. Any is not a schema-less alternative; it is a schema-dispatch mechanism. Any assumes a schema exists and the consumer knows it, while Value assumes no schema exists at all. The comparison is useful because teams often reach for Value when their actual requirement is polymorphism rather than truly schema-less data.
A Note on Payload Decoding in Benchmarks: To ensure a fair, apples-to-apples performance comparison, the unmarshaling benchmarks for all variants fully deserialize both the outer envelope and the inner dynamic/polymorphic payloads:
- In the
google.protobuf.Anybenchmark, the payload is not left as raw bytes; it is fully unpacked into a concrete statically-compiled Go struct usinganypb.UnmarshalTo(). - In the
Protobuf + JSONbenchmark, the inner JSON string is not left unparsed; it is fully deserialized into a concrete Go struct using standardjson.Unmarshal(). - In the
google.protobuf.Valuebenchmark, the payload is fully parsed into a tree of Go objects representing the JSON-like data.
Importantly, in real-world applications, both google.protobuf.Any and opaque Protobuf + JSON packaging allow you to bypass this inner parsing entirely on intermediate routing nodes (deferred/lazy parsing). This is a significant architectural advantage if intermediate services only need to forward or store the payload without inspecting it. However, to maintain a level playing field and measure the actual parsing cost, these benchmarks force full decoding.
To handle arbitrary data, the dynamic Protobuf configurations rely on standard structpb definitions:
syntax = "proto3";
package event;
import "google/protobuf/struct.proto";
message EventEnvelope {
string id = 1;
int64 timestamp = 2;
google.protobuf.Value payload = 3; // Dynamic payload field
}
Benchmark Disclaimer and Workload Caveats
As with all performance testing, microbenchmarks should be taken with a grain of salt. Actual performance will vary depending on your specific hardware, operating system, compiler version, garbage collection settings, and payload structure. Workload shape and configuration matter enormously. Map-heavy workloads are particularly pathological for google.protobuf.Struct due to Go’s map lookup and insertion overhead. Deeply nested objects increase allocation and traversal costs substantially, whereas flat structures suffer less. Furthermore, implementation details like hot-path struct reuse or memory pooling (such as a thread-local arena pool solution using dynamic parser bytecode) can dramatically change outcomes in production, shifting the bottleneck back toward wire serialization and parsing logic.
Microbenchmarks represent synthetic workloads and may not perfectly translate to the performance profile of a complex, production system. You should always run benchmarks under your own representative workloads before making significant architectural decisions.
This article focuses specifically on Go’s protobuf implementation and the structpb runtime model. Other languages may exhibit different allocation and parsing characteristics, though the wire-format overhead discussed here remains universal.
Additionally, dynamic Protobuf is not always a poor choice. For admin panels, configuration APIs, low-volume integrations, or systems where schema flexibility matters more than throughput, google.protobuf.Value remains a perfectly reasonable choice. It is only when these structures sit directly on high-throughput hot paths that performance problems emerge.
Benchmark Results
The benchmarks were executed under Go 1.26 on an Apple M1 Pro.
Wire Size
First, I compared the serialized payload size of each configuration. Because Protobuf is famous for being compact, developers often assume even unstructured dynamic payloads using google.protobuf.Value will be smaller than standard JSON. Measuring serialized byte sizes is a straightforward test that yields definitive, objective results.
Note: These measurements reflect raw serialized payload size before transport-level compression. Systems using gzip or zstd may observe different relative wire sizes. Repeated JSON keys compress extremely well, though Protobuf map entries and repeated keys also benefit heavily from transport compression.
Show data table
| Format / Config (Medium Payload) | Serialized Size | % of JSON (lower is better) |
|---|---|---|
| Concrete (JSON) | 291 B | 100.0% (Baseline) |
| Concrete (proto) / Concrete (vtproto) | 162 B | 55.7% |
| google.protobuf.Any (proto) | 212 B | 72.9% |
| Protobuf + JSON | 294 B | 101.0% |
| google.protobuf.Value (proto) | 328 B | 112.7% |
| Concrete (JSONProto) | 293 B | 100.7% |
| google.protobuf.Value (JSONProto) | 291 B | 100.0% |
| google.protobuf.Any (JSONProto) | 349 B | 119.9% |
Processing Throughput
Wire size is only half the battle; parsing these dynamic structures introduces significant CPU and memory costs. Building and parsing schema-less Protobuf trees involves significant pointer-wrapping overhead, resulting in higher CPU usage and frequent heap allocations. Standard concrete Protobuf marshals almost instantly, and PlanetScale’s reflection-free generator Concrete (vtproto) is the absolute fastest. Much of vtproto’s advantage comes from eliminating reflection and generating specialized straight-line serialization code ahead of time.
Show data table
| Benchmark (Medium Payload) | ns/op | Memory (B/op) | Allocations/op |
|---|---|---|---|
| Concrete (vtproto) | 123 ns | 176 B | 1 |
| google.protobuf.Any (proto) | 127 ns | 224 B | 1 |
| Concrete (proto) | 356 ns | 176 B | 1 |
| Concrete (JSON) | 634 ns | 464 B | 2 |
| Protobuf + JSON | 844 ns | 1,024 B | 4 |
| Concrete (JSONv2) | 985 ns | 608 B | 3 |
| Map (JSONv2) | 1,835 ns | 456 B | 12 |
| Map (JSON) | 2,346 ns | 1,200 B | 28 |
| Concrete (JSONProto) | 2,797 ns | 1,722 B | 34 |
| google.protobuf.Value (proto) | 4,872 ns | 736 B | 25 |
| google.protobuf.Value (JSONProto) | 7,828 ns | 2,750 B | 70 |
The most surprising finding here is not that Value is slower than static Protobuf. Everyone expects that. The headline-worthy result is this: in these benchmarks, dynamic Protobuf frequently loses to plain JSON as well.
For a medium payload, standard static Protobuf is 13x faster than dynamic binary Value serialization, but standard JSON is over 7x faster than Value. When evaluating unmarshaling, the gap widens further:
Show data table
| Benchmark (Medium Payload) | ns/op | Memory (B/op) | Allocations/op |
|---|---|---|---|
| Concrete (vtproto) | 383 ns | 432 B | 14 |
| Concrete (proto) | 690 ns | 560 B | 15 |
| google.protobuf.Any (proto) | 906 ns | 864 B | 18 |
| Concrete (JSONv2) | 1,410 ns | 256 B | 4 |
| Map (JSONv2) | 2,595 ns | 1,392 B | 30 |
| Concrete (JSON) | 3,659 ns | 688 B | 19 |
| Protobuf + JSON | 3,970 ns | 1,392 B | 22 |
| Map (JSON) | 4,177 ns | 1,856 B | 54 |
| Concrete (JSONProto) | 4,659 ns | 1,304 B | 58 |
| google.protobuf.Value (proto) | 5,686 ns | 2,888 B | 90 |
| google.protobuf.Value (JSONProto) | 10,819 ns | 4,080 B | 145 |
Dynamic binary parsing takes 5,772 ns and requires 90 allocations, compared to just 674 ns and 15 allocations for standard static Protobuf.
Appendix: The Hidden Cost of Construction
To keep comparisons apples-to-apples, the main benchmarks above isolate the pure marshaling (serialization) and unmarshaling (deserialization) phases using pre-constructed message structures.
However, in real-world systems, using dynamic types introduces a secondary runtime overhead: translating native Go data structures (like map[string]any) into structpb.Value (construction) before serialization, and converting them back (using .AsInterface()) after deserialization.
The table below shows the cost of these construction and conversion steps across our three payload sizes:
| Payload | Phase | Time (ns/op) | Memory (B/op) | Allocations/op |
|---|---|---|---|---|
| Small | Construction (NewValue) | 504 ns | 671 B | 13 |
Conversion (AsInterface) | 300 ns | 368 B | 5 | |
| Medium | Construction (NewValue) | 2,076 ns | 2,223 B | 43 |
Conversion (AsInterface) | 1,127 ns | 1,240 B | 19 | |
| Large | Construction (NewValue) | 200,092 ns | 223,407 B | 4,305 |
Conversion (AsInterface) | 129,140 ns | 125,818 B | 1,902 |
For a medium payload, constructing the structpb.Value and converting it back adds an extra 3.2 microseconds and 62 heap allocations to the overall lifecycle. When combined with the marshaling and unmarshaling costs, these conversion steps represent a significant hidden performance penalty in high-throughput Go services.
The Root Cause: Allocations and Pointer Chasing
With the wire and processing numbers in hand, we can look at the mechanical reasons behind the overhead.
- Go Runtime Allocations: Because Go is statically typed, representing a polymorphic JSON-like tree requires nesting interfaces and pointers. Deserializing a dynamic
Valuepayload requires Go’s runtime to allocate a unique*structpb.Valuepointer for every single map key, list item, and value in the tree. Every node in the tree is represented as a separate Protobuf message (Value,Struct, orListValue), requiring recursive traversal during serialization and deserialization. On a large payload, this creates over 9,000 individual heap allocations, putting immense pressure on Go’s garbage collector and memory allocator. Furthermore,structpbrelies heavily on interface boxing. Pushing values through these abstraction layers prevents the compiler from applying the aggressive optimizations it normally uses for generated static structs. - Cache Locality and Memory Layout: At the systems level, statically generated Protobuf messages compile to flat, cache-friendly Go structs representing contiguous (or near-contiguous) memory blocks. In contrast,
structpb.Valueconstructs a highly fragmented graph of heap-allocated objects connected by pointers. Traversing this tree-shaped structure causes frequent pointer chasing, which hurts cache locality, increases L1/L2 cache misses, and hinders branch prediction. Because the pointer graph fragments memory, the CPU prefetcher cannot effectively predict the next memory address, leading to those cache misses.
Are You Actually Solving a Dynamic Data Problem?
In practice, many google.protobuf.Value fields are not truly dynamic. They are often legacy JSON blobs carried forward during API migrations. Before reaching for Value, ask whether the payload has a finite, documented structure. If it does, a normal Protobuf message is usually the better long-term design. A dynamic field is often a temporary migration artifact rather than a genuine domain requirement. If the field’s shape is stable enough to document, it is usually stable enough to model as a statically typed Protobuf message.
High-Performance Alternatives
Importantly, this does not mean runtime Protobuf parsing itself is inherently slow. Rather, the real bottleneck is schema-less, JSON-style polymorphism layered onto Protobuf through Struct and Value.
If your system requires runtime schema flexibility, avoid google.protobuf.Struct for high-throughput paths and leverage these specific optimizations depending on your runtime requirements:
Polymorphism: Use google.protobuf.Any
When data conforms to a known set of pre-compiled schemas, wrap the fields in an Any message. It records a clean type_url string alongside raw compiled binary bytes.
- Pros: Highly compact (212 bytes for a medium payload) and fast. Marshaling is over 35x faster than using generic values.
- Cons: Requires compile-time schema awareness for all incoming types. Crucially, the consuming service must have the exact generated Go types compiled and registered in its binary to cleanly unpack the message (e.g., via
anypb.UnmarshalTo()). If the global protobuf registry lacks the specific type matching the incomingtype_url, unmarshaling will fail. This strict coupling highlights whyAnyis a schema-dispatch mechanism, not a drop-in replacement for fully unstructured JSON. Additionally,Anycarries the wire overhead of serializing thetype_urlstring (e.g.,type.googleapis.com/package.Message), which adds a few dozen bytes depending on your package name length. This explains the size increases forAnyvisible in the benchmark charts compared to native static Protobuf.
Recommendations
To help choose the right design pattern for your dynamic payloads, you can follow this decision flow:
Model your actual data in Protobuf
Statically defining your schemas is always the ideal path. It yields the best performance, full compile-time type safety, and clear API contracts. Commit to first-class, statically typed fields whenever possible. It’s worth it.
Use a native map<string, string> for flat attributes
If your metadata is strictly flat key-value strings (like HTTP headers or tags), use a native map<string, string>. It converts cleanly to a native Go map without pointer wrapping or parsing overhead.
Use google.protobuf.Any for middle-layer opacity
If you have opaque data that you don’t want intermediate routing nodes to parse, wrap the payload in a google.protobuf.Any message. This allows middle layers to forward or store packets without deserialization, while downstream consumers can decode the payload cleanly using pre-compiled schemas.
Pack raw JSON into strings (Opaque JSON Packaging)
If you must support unstructured data on a high-throughput, latency-sensitive hot path, storing the dynamic data as raw JSON directly inside a standard string or bytes Protobuf field is often the best choice.
Let’s be honest: this feels gross. Wrapping raw, stringified JSON inside a Protobuf binary envelope violates schema purity, makes API documentation messy, and is generally a dirty hack. Specifically, it breaks schema-first workflows: API tooling (like OpenAPI generators, Buf, or gRPC gateways) that relies on strict contracts cannot introspect the structure of the JSON payload. This forces downstream consumers to rely on out-of-band documentation rather than compile-time safety and automatic code generation, making it a last-resort optimization.
But pragmatism sometimes has to beat purity in high-throughput systems. If you are on a high-throughput, latency-sensitive hot path, the numbers don’t care about architectural aesthetics. Bypassing dynamic WKT parsing in favor of opaque JSON packaging saves a lot of CPU cycles and heap allocations. Among the approaches evaluated here, opaque JSON packaging provides the best performance/flexibility tradeoff for fully unstructured payloads. You may not like it, but this might be what peak performance looks like:
message EventEnvelope {
string event_json = 1;
int64 timestamp = 2;
}
Use google.protobuf.Value for low-throughput dynamic data
If you just want a quick, standardized way to represent arbitrary JSON-like structures in Protobuf and your throughput/latency budgets aren’t tight, using the built-in Well-Known Types is completely fine and requires the least custom logic.
Conclusion
Static Protobuf delivers its benefits because the schema is known ahead of time. google.protobuf.Value intentionally gives up that information in exchange for flexibility. In Go, that tradeoff can be surprisingly expensive in both wire size and runtime cost. If your payload has a schema, model it. If it doesn’t and performance matters, opaque JSON may outperform dynamic Protobuf despite being less elegant.
