gRPC Performance: Using Binary Payloads to Decrease Message Size
1. Why Does Payload Size Matter?
Every byte that travels over the network costs time and bandwidth. When your gRPC service is called thousands of times per minute, even a small reduction in the size of each message can have a measurable impact on overall throughput and latency.
Text-based formats such as JSON represent data in a human-readable way, which is convenient but verbose. If you have a large chunk of data that can be represented in binary (for example, an image, a compressed file, or a blob of sensor readings), storing it as a string forces the serializer to encode every byte as characters, which is wasteful.
Protobuf has a dedicated type — bytes — that stores raw binary data with no encoding overhead. When you use bytes, Protobuf simply writes the raw bytes directly into the serialized message, making it as compact as possible.
Real-world analogy: Imagine shipping a photograph. If you describe the photograph in a letter — "pixel at row 1, column 1 is red; pixel at row 1, column 2 is blue..." — the letter is enormous. If you just send the photograph file itself in a sealed envelope, it is far smaller and faster to transmit. The bytes type in Protobuf is the envelope approach.2. What Is the Protobuf bytes Type?
The Protobuf bytes type represents an arbitrary sequence of raw bytes. In C#, the generated code maps a bytes field to the ByteString class from the Google.Protobuf namespace.
ByteString is an immutable, memory-safe wrapper around a byte array. It is designed for high performance: it avoids unnecessary copies where possible and works well with Protobuf's zero-copy serialization infrastructure.
Key facts about ByteString:
- It lives in the
Google.Protobufnamespace — you need ausing Google.Protobuf;directive. - It is immutable — once created, the contents cannot be changed.
- It can be created from a plain
byte[]array using two different methods that have different performance and safety trade-offs (explained below). - It can be read back as a
byte[]or as aReadOnlyMemory<byte>.
3. Step 1: Add Binary Fields to the .proto File
Suppose we want to add two binary data fields to our performance monitoring response. In the .proto file, this is done with the bytes keyword:
After adding these fields and rebuilding the project, the generated PerformanceStatusResponse class will have two new properties: DataLoad1 and DataLoad2, both of type ByteString.
4. Two Ways to Create a ByteString (Write)
When you want to set a bytes field in a Protobuf message, you need to convert your C# byte[] array into a ByteString. There are two ways to do this, and they behave very differently:
| Method | Makes a copy? | Speed | Safety |
|---|---|---|---|
UnsafeByteOperations.UnsafeWrap(array) | No — wraps the original array | Faster (zero allocation) | Unsafe if the original array is mutated later |
ByteString.CopyFrom(array) | Yes — copies all bytes | Slower (allocation + copy) | Always safe — isolated copy |
5. Method A: UnsafeByteOperations.UnsafeWrap — Fast But Careful
UnsafeByteOperations.UnsafeWrap wraps an existing byte array inside a ByteString without copying it. The ByteString and the original array share the same memory.
Why is it called "Unsafe"? Because if you later change any value in rawData, those changes will also be visible through payload. ByteString is supposed to be immutable, but UnsafeWrap bypasses that guarantee by sharing memory.
When is it safe? If you create a byte array, immediately wrap it and never touch the array again (for example, you discard the reference), UnsafeWrap is perfectly safe and gives you the best possible performance.
6. Method B: ByteString.CopyFrom — Safe and Simple
ByteString.CopyFrom creates a brand-new byte array, copies all bytes from the source into it, and returns a ByteString that owns that copy. Because the ByteString has its own independent copy, you can freely modify the original array afterwards without affecting the message.
The trade-off is that CopyFrom allocates new memory and copies every byte, which is more expensive for large payloads. For small payloads or situations where safety is more important than maximum throughput, this is the right choice.
Rule of thumb: UseUnsafeWrapwhen you own the byte array and will not touch it after wrapping. UseCopyFromwhen the byte array comes from an external source (a buffer you share with something else) or when mutation after the fact is a possibility.
7. Two Ways to Read a ByteString Back (Read)
On the receiving end (for example, in the client that received the gRPC response), you need to convert the ByteString back into a byte[] to do something useful with it. Again, there are two paths.
Method A: ToByteArray() — Simple and Always Works
The simplest way to extract bytes is ToByteArray(). It always works and returns a plain byte[]:
The downside is that ToByteArray() always allocates a new array and copies all bytes. For high-frequency code paths, this creates garbage collection pressure.
Method B: MemoryMarshal.TryGetArray — Allocation-Free When Possible
If you want to avoid the extra allocation from ToByteArray(), you can try to get a reference to the underlying memory directly using MemoryMarshal.TryGetArray:
When TryGetArray succeeds, there is no memory allocation and no copy — you are reading directly from the memory that Protobuf already owns. This is the most efficient way to read binary data.
Why does it sometimes fail? A ByteString's memory might not always be backed by a plain array (it could be native memory or a memory-mapped segment). The fallback Memory.ToArray() handles this case.
| Read method | Allocation | Always works? | Best for |
|---|---|---|---|
ToByteArray() | Yes — new array each time | Yes | Simple code, infrequent reads |
MemoryMarshal.TryGetArray | No (if it succeeds) | No — needs fallback | Hot paths, high-frequency deserialization |
8. Step 4: Update Your Response Model
If you map the gRPC response to your own application model (a common pattern to decouple your application logic from the generated Protobuf types), you need to add properties to hold the binary data:
In the mapping code inside your gRPC client wrapper, you extract the bytes when building the model:
9. Which Method Should You Use?
Here is a practical summary to help you choose:
| Scenario | Write recommendation | Read recommendation |
|---|---|---|
| Small payloads (< a few KB), simple code | CopyFrom | ToByteArray() |
| Large payloads, called very frequently | UnsafeWrap (if you own the array) | TryGetArray with fallback |
| Shared/external byte array that might change | CopyFrom | ToByteArray() |
| Freshly created local byte array, not shared | UnsafeWrap safe to use | Either method |
Remember: "Unsafe" inUnsafeWrapdoes not mean it will crash or corrupt memory randomly. It means it bypasses the immutability guarantee ofByteString. As long as you do not mutate the source array after wrapping, it is completely safe to use.
10. Summary
- Protobuf's
bytestype stores raw binary data compactly with no encoding overhead, making messages smaller compared to text-based representations. - In C#,
bytesfields are represented byByteStringfrom theGoogle.Protobufnamespace. - To define a binary field in a
.protofile:bytes data_load_1 = 5; - Writing — two approaches:
UnsafeByteOperations.UnsafeWrap(array)— wraps the array without copying; fast but the source array must not be mutated afterward.ByteString.CopyFrom(array)— copies all bytes; safer but slightly more expensive.- Reading — two approaches:
byteString.ToByteArray()— always returns a newbyte[]; simple and safe.MemoryMarshal.TryGetArray(byteString.Memory, out var segment)— tries to get the underlying array directly with zero allocation; needs a fallback.- Use the allocation-free methods on high-frequency code paths (thousands of calls per second) and the safer, simpler methods when frequency is low or when correctness is more important than raw speed.