gRPC With. Net Performance Created: 26 Mar 2026 Updated: 26 Mar 2026

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:

  1. It lives in the Google.Protobuf namespace — you need a using Google.Protobuf; directive.
  2. It is immutable — once created, the contents cannot be changed.
  3. It can be created from a plain byte[] array using two different methods that have different performance and safety trade-offs (explained below).
  4. It can be read back as a byte[] or as a ReadOnlyMemory<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:

// performance.proto
syntax = "proto3";

package performance;

service Monitor {
rpc GetPerformance (PerformanceStatusRequest) returns (PerformanceStatusResponse);
rpc GetManyPerformanceStats (stream PerformanceStatusRequest)
returns (stream PerformanceStatusResponse);
}

message PerformanceStatusRequest {
string client_name = 1;
}

message PerformanceStatusResponse {
double cpu_percentage_usage = 1;
double memory_usage = 2;
int32 processes_running = 3;
int32 active_connections = 4;
bytes data_load_1 = 5; // <-- new binary field
bytes data_load_2 = 6; // <-- new binary field
}

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:

MethodMakes a copy?SpeedSafety
UnsafeByteOperations.UnsafeWrap(array)No — wraps the original arrayFaster (zero allocation)Unsafe if the original array is mutated later
ByteString.CopyFrom(array)Yes — copies all bytesSlower (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.

using Google.Protobuf;

// You have a byte array from some source (e.g., a sensor reading)
byte[] rawData = GetDataFromSensor();

// Wrap it without copying — FAST
ByteString payload = UnsafeByteOperations.UnsafeWrap(rawData);

// Assign to the proto message field
response.DataLoad1 = payload;

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.

// DANGEROUS — do NOT do this after using UnsafeWrap!
byte[] rawData = GetDataFromSensor();
ByteString payload = UnsafeByteOperations.UnsafeWrap(rawData);

rawData[0] = 99; // This also changes payload[0]! Unexpected!
response.DataLoad1 = payload; // Corrupted data!

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.

// SAFE — the array is created locally and never modified
ByteString CreateSafePayload()
{
// Create is local, wrap immediately, return — no mutation possible
return UnsafeByteOperations.UnsafeWrap(new byte[] { 10, 20, 30 });
}

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.

using Google.Protobuf;

byte[] rawData = GetDataFromSensor();

// Copy every byte into a new allocation — SAFE
ByteString payload = ByteString.CopyFrom(rawData);

response.DataLoad2 = payload;

// Safe to modify rawData after this — payload is unaffected
rawData[0] = 99; // Does NOT change payload

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: Use UnsafeWrap when you own the byte array and will not touch it after wrapping. Use CopyFrom when 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[]:

// On the client side, after receiving the response:
ByteString rawPayload = response.DataLoad1;

// Simple: always returns a new byte array
byte[] data = rawPayload.ToByteArray();

ProcessData(data);

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:

using System.Runtime.InteropServices;
using Google.Protobuf;

ByteString rawPayload = response.DataLoad2;

// Try to get a reference to the underlying memory with no copy
if (MemoryMarshal.TryGetArray(rawPayload.Memory, out ArraySegment<byte> segment))
{
// Success — 'segment' points to the same memory as rawPayload
// No allocation, no copy
ProcessData(segment.Array, segment.Offset, segment.Count);
}
else
{
// Fallback — if the memory is not backed by an array,
// we must copy it the slow way
byte[] data = rawPayload.Memory.ToArray();
ProcessData(data, 0, data.Length);
}

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 methodAllocationAlways works?Best for
ToByteArray()Yes — new array each timeYesSimple code, infrequent reads
MemoryMarshal.TryGetArrayNo (if it succeeds)No — needs fallbackHot 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:

// ResponseModel.cs — application-level model
namespace ResponseModel
{
public class PerformanceStatusModel
{
public double CpuPercentageUsage { get; set; }
public double MemoryUsage { get; set; }
public int ProcessesRunning { get; set; }
public int ActiveConnections { get; set; }
public byte[] DataLoad1 { get; set; } // <-- new
public byte[] DataLoad2 { get; set; } // <-- new
}
}

In the mapping code inside your gRPC client wrapper, you extract the bytes when building the model:

// Inside the client wrapper — mapping proto response to application model
var model = new ResponseModel.PerformanceStatusModel
{
CpuPercentageUsage = response.CpuPercentageUsage,
MemoryUsage = response.MemoryUsage,
ProcessesRunning = response.ProcessesRunning,
ActiveConnections = response.ActiveConnections,
DataLoad1 = response.DataLoad1.ToByteArray(), // simple approach
DataLoad2 = ExtractBytes(response.DataLoad2) // allocation-free approach
};

// Helper using the allocation-free path with fallback
private static byte[] ExtractBytes(Google.Protobuf.ByteString payload)
{
if (System.Runtime.InteropServices.MemoryMarshal.TryGetArray(
payload.Memory, out var segment))
{
// Return the underlying array segment — no copy
// Note: if you need an isolated copy, call segment.ToArray() here
return segment.Array;
}
return payload.Memory.ToArray();
}

9. Which Method Should You Use?

Here is a practical summary to help you choose:

ScenarioWrite recommendationRead recommendation
Small payloads (< a few KB), simple codeCopyFromToByteArray()
Large payloads, called very frequentlyUnsafeWrap (if you own the array)TryGetArray with fallback
Shared/external byte array that might changeCopyFromToByteArray()
Freshly created local byte array, not sharedUnsafeWrap safe to useEither method
Remember: "Unsafe" in UnsafeWrap does not mean it will crash or corrupt memory randomly. It means it bypasses the immutability guarantee of ByteString. As long as you do not mutate the source array after wrapping, it is completely safe to use.

10. Summary

  1. Protobuf's bytes type stores raw binary data compactly with no encoding overhead, making messages smaller compared to text-based representations.
  2. In C#, bytes fields are represented by ByteString from the Google.Protobuf namespace.
  3. To define a binary field in a .proto file: bytes data_load_1 = 5;
  4. Writing — two approaches:
  5. UnsafeByteOperations.UnsafeWrap(array) — wraps the array without copying; fast but the source array must not be mutated afterward.
  6. ByteString.CopyFrom(array) — copies all bytes; safer but slightly more expensive.
  7. Reading — two approaches:
  8. byteString.ToByteArray() — always returns a new byte[]; simple and safe.
  9. MemoryMarshal.TryGetArray(byteString.Memory, out var segment) — tries to get the underlying array directly with zero allocation; needs a fallback.
  10. 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.
Share this lesson: