Introduction
In .NET, REST APIs have been the default for years because they are simple, widely supported, and ideal for public APIs.
The way systems are built in 2025, however, is very different from the era when REST became popular.
The following are common components of enterprise platforms today:
Microservices in the dozens (or hundreds)
Dashboards and notifications in real-time
Monitoring and telemetry pipelines (IoT)
Workflows driven by AI (agent pipelines, inference, streaming outputs)
Services communicate constantly in these environments, sometimes thousands of times per second.
Not because REST is "bad", but because HTTP/1.1 + JSON introduce overhead that becomes expensive at scale.
gRPC excels in this area.
It is designed for machine-to-machine communication. It uses:
Two-way HTTP
Buffers for Protocols (Protobuf)
Strongly typed clients were generated
Streaming support built-in
I will walk you through a practical gRPC customer microservice demo using C# 14 + .NET 10, and compare JSON vs Protobuf payload performance.
Why REST Starts to Hurt Inside Distributed Systems
At the "edge" of a system, REST is brilliant:
However, internal service-to-service communication has a different priority.
JSON payloads are expensive under load
Microservices don't need human-readable formats, even though JSON is human-readable.
JSON becomes expensive at scale because:
Property names repeated (larger payloads)
During serialization, allocations are made
Cost of parsing during deserialization
Pressure increase in the GC
This becomes a permanent performance tax on a platform with constant inter-service calls.
REST contracts are soft contracts
It helps to use Swagger/OpenAPI, but it does not guarantee compatibility.
Real-world teams frequently experience contract drift:
Field renaming
Fields that are required have been added
The type has changed (string to number)
Nullability assumptions break consumers
These problems usually arise at runtime, and runtime failures are expensive in distributed systems.
Streaming is not first-class
Streaming is essential for modern workloads:
The REST API can do it, but typically through workarounds:
As a result, complexity and operational burden increase.
What gRPC Solves (In Practical Enterprise Terms)
Multiplexing HTTP/2
HTTP/2 is properly used by gRPC:
Relationships that last a lifetime
Streams that are multiplexed
Compression of headers
With fewer open sockets, throughput is improved
Fast and compact protobuf
The protobuf format is binary:
JSON payloads are smaller
Serialization and deserialization are faster
Language-independent behavior
APIs based on contracts
The contract for gRPC is defined in the .proto file.
Code is generated for the server
Code is generated for the client
During the build process, breaking changes are caught
By itself, that reduces integration drift dramatically.
There is built-in streaming
Natively supported are unary calls and streaming modes.
The software does not support bolt-ons, custom message envelopes, or handwritten state management.
The Honest Part: When REST is Still the Better Choice
As a Microsoft MVP, let me be clear:
REST cannot be replaced everywhere by gRPC.
It is often better to rest when:
The best model for real-world architecture is typically:
At the edge, REST
Platform-based gRPC
Code Example of Customer Microservice with gRPC (C# 14 + .NET 10)
![Customer-Microservice=Demo-By-Ziggy-Rafiq]()
Solution Structure
![Solution_Structure_by_Ziggy_Rafiq]()
Step 1 — Define the API Contract (customer.proto)
File: CustomerGrpc.Server/Protos/customer.proto
syntax = "proto3";
option csharp_namespace = "CustomerGrpc";
package customer.v1;
service CustomerService {
rpc CreateCustomer (CreateCustomerRequest) returns (CustomerResponse);
rpc GetCustomerById (GetCustomerByIdRequest) returns (CustomerResponse);
rpc StreamCustomers (StreamCustomersRequest) returns (stream CustomerResponse);
}
message CreateCustomerRequest {
string full_name = 1;
string email = 2;
}
message GetCustomerByIdRequest {
string customer_id = 1;
}
message StreamCustomersRequest {
int32 delay_ms = 1;
}
message CustomerResponse {
string customer_id = 1;
string full_name = 2;
string email = 3;
string created_utc = 4;
}
The API contract generates strongly typed C# classes from this file.
Step 2 — Implement the Server (CustomerRepository)
File: CustomerGrpc.Server/Data/CustomerRepository.cs
namespace CustomerGrpc.Server.Data;
public sealed record Customer(
Guid Id,
string FullName,
string Email,
DateTime CreatedUtc);
public interface ICustomerRepository
{
Customer Add(string fullName, string email);
Customer? Get(Guid id);
IReadOnlyList<Customer> GetAll();
}
public sealed class CustomerRepository : ICustomerRepository
{
private readonly List<Customer> _customers = [];
public Customer Add(string fullName, string email)
{
var customer = new Customer(Guid.NewGuid(), fullName, email, DateTime.UtcNow);
_customers.Add(customer);
return customer;
}
public Customer? Get(Guid id) => _customers.FirstOrDefault(x => x.Id == id);
public IReadOnlyList<Customer> GetAll() => _customers.AsReadOnly();
}
Step 3 — CustomerService Implementation (Unary + Streaming)
File: CustomerGrpc.Server/Services/CustomerService.cs
using CustomerGrpc.Server.Data;
using Grpc.Core;
namespace CustomerGrpc.Server.Services;
public sealed class CustomerGrpcService(
ILogger<CustomerGrpcService> logger,
ICustomerRepository repository)
: CustomerGrpc.CustomerService.CustomerServiceBase
{
public override Task<CustomerResponse> CreateCustomer(
CreateCustomerRequest request,
ServerCallContext context)
{
if (string.IsNullOrWhiteSpace(request.FullName))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Full name is required."));
if (string.IsNullOrWhiteSpace(request.Email) || !request.Email.Contains('@'))
throw new RpcException(new Status(StatusCode.InvalidArgument, "A valid email is required."));
var customer = repository.Add(request.FullName.Trim(), request.Email.Trim());
logger.LogInformation("Customer created: {CustomerId} ({Email})", customer.Id, customer.Email);
return Task.FromResult(Map(customer));
}
public override Task<CustomerResponse> GetCustomerById(
GetCustomerByIdRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.CustomerId, out var id))
throw new RpcException(new Status(StatusCode.InvalidArgument, "CustomerId must be a valid GUID."));
var customer = repository.Get(id);
if (customer is null)
throw new RpcException(new Status(StatusCode.NotFound, $"Customer '{id}' not found."));
return Task.FromResult(Map(customer));
}
public override async Task StreamCustomers(
StreamCustomersRequest request,
IServerStreamWriter<CustomerResponse> responseStream,
ServerCallContext context)
{
var delayMs = request.DelayMs <= 0 ? 250 : request.DelayMs;
foreach (var customer in repository.GetAll())
{
if (context.CancellationToken.IsCancellationRequested)
break;
await responseStream.WriteAsync(Map(customer));
await Task.Delay(delayMs, context.CancellationToken);
}
}
private static CustomerResponse Map(Customer customer) => new()
{
CustomerId = customer.Id.ToString(),
FullName = customer.FullName,
Email = customer.Email,
CreatedUtc = customer.CreatedUtc.ToString("O")
};
}
Step 4 — Hosting in Program.cs
File: CustomerGrpc.Server/Program.cs
using CustomerGrpc.Server.Data;
using CustomerGrpc.Server.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
builder.Services.AddSingleton<ICustomerRepository, CustomerRepository>();
builder.Services.AddLogging(x => x.AddConsole());
var app = builder.Build();
app.MapGrpcService<CustomerGrpcService>();
app.MapGet("/", () => "Customer gRPC service running.");
app.Run();
Step 5 — Client (Unary + Streaming)
File: CustomerGrpc.Client/Program.cs
using CustomerGrpc;
using Grpc.Core;
using Grpc.Net.Client;
Console.WriteLine("Hello, from Ziggy Rafiq!");
Console.WriteLine("Customer gRPC demo client starting...");
using var channel = GrpcChannel.ForAddress("https://localhost:7118");
var client = new CustomerService.CustomerServiceClient(channel);
var created = await client.CreateCustomerAsync(new CreateCustomerRequest
{
FullName = "Ziggy Rafiq",
Email = "[email protected]"
});
Console.WriteLine($"Created: {created.CustomerId} - {created.FullName}");
var fetched = await client.GetCustomerByIdAsync(new GetCustomerByIdRequest
{
CustomerId = created.CustomerId
});
Console.WriteLine($"Fetched: {fetched.CustomerId} - {fetched.Email}");
Console.WriteLine("\nStreaming customers...");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var stream = client.StreamCustomers(new StreamCustomersRequest { DelayMs = 300 }, cancellationToken: cts.Token);
await foreach (var item in stream.ResponseStream.ReadAllAsync(cts.Token))
{
Console.WriteLine($"→ {item.CustomerId} | {item.FullName} | {item.Email}");
}
Console.ReadLine();
BenchmarkDotNet: JSON vs Protobuf
Serialization costs are a significant component of latency and CPU usage in internal services.
We get real numbers from BenchmarkDotNet rather than opinions.
To measure the real differences between REST-style JSON and gRPC-style Protobuf, we use BenchmarkDotNet instead of guessing.
Run the benchmarks
dotnet run -c Release --project .\CustomerGrpc.Benchmarks\CustomerGrpc.Benchmarks.csproj
Benchmark files included in this demo
Protobuf message contract
File: CustomerGrpc.Benchmarks/Protos/customer_benchmark.proto
The benchmark uses the following Protobuf payload:
syntax = "proto3";
option csharp_namespace = "CustomerGrpc.Benchmarks.Protos";
package customer.bench.v1;
message CustomerProto {
string customer_id = 1;
string full_name = 2;
string email = 3;
string created_utc = 4;
}
JSON DTO model (REST-style)
File: CustomerGrpc.Benchmarks/Models/CustomerJsonDto.cs
A typical REST payload looks like this:
namespace CustomerGrpc.Benchmarks.Models;
public sealed record CustomerJsonDto(
Guid CustomerId,
string FullName,
string Email,
DateTime CreatedUtc
);
Benchmark suite (JSON vs Protobuf)
File: CustomerGrpc.Benchmarks/Benchmarks/JsonVsProtobufBenchmarks.cs
Payload size, serialization and deserialization time, and allocations are measured:
using BenchmarkDotNet.Attributes;
using CustomerGrpc.Benchmarks.Models;
using CustomerGrpc.Benchmarks.Protos;
using Google.Protobuf;
using System.Text.Json;
namespace CustomerGrpc.Benchmarks.Benchmarks;
[MemoryDiagnoser]
public class JsonVsProtobufBenchmarks
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private CustomerJsonDto _jsonDto = default!;
private CustomerProto _proto = default!;
private byte[] _jsonBytes = default!;
private byte[] _protoBytes = default!;
[GlobalSetup]
public void Setup()
{
_jsonDto = new CustomerJsonDto(
CustomerId: Guid.NewGuid(),
FullName: "Ziggy Rafiq",
Email: "[email protected]",
CreatedUtc: DateTime.UtcNow);
_proto = new CustomerProto
{
CustomerId = _jsonDto.CustomerId.ToString(),
FullName = _jsonDto.FullName,
Email = _jsonDto.Email,
CreatedUtc = _jsonDto.CreatedUtc.ToString("O")
};
// Pre-serialized payloads for deserialize benchmarks
_jsonBytes = JsonSerializer.SerializeToUtf8Bytes(_jsonDto, JsonOptions);
_protoBytes = _proto.ToByteArray();
}
// -----------------------------
// Size comparisons (no timing)
// -----------------------------
[Benchmark(Description = "JSON Size (bytes)")]
public int Json_Size() => _jsonBytes.Length;
[Benchmark(Description = "Protobuf Size (bytes)")]
public int Protobuf_Size() => _protoBytes.Length;
// -----------------------------
// Serialization
// -----------------------------
[Benchmark(Baseline = true, Description = "JSON Serialize")]
public byte[] Json_Serialize()
=> JsonSerializer.SerializeToUtf8Bytes(_jsonDto, JsonOptions);
[Benchmark(Description = "Protobuf Serialize")]
public byte[] Protobuf_Serialize()
=> _proto.ToByteArray();
// -----------------------------
// Deserialization
// -----------------------------
[Benchmark(Description = "JSON Deserialize")]
public CustomerJsonDto? Json_Deserialize()
=> JsonSerializer.Deserialize<CustomerJsonDto>(_jsonBytes, JsonOptions);
[Benchmark(Description = "Protobuf Deserialize")]
public CustomerProto Protobuf_Deserialize()
=> CustomerProto.Parser.ParseFrom(_protoBytes);
}
Benchmark entry point
File: CustomerGrpc.Benchmarks/Program.cs
using BenchmarkDotNet.Running;
using CustomerGrpc.Benchmarks.Benchmarks;
BenchmarkRunner.Run<JsonVsProtobufBenchmarks>();
What to look for in results
Most developers are surprised by how much JSON allocates under load. Lower allocations and smaller payloads usually translate into better throughput and lower hosting costs.
Conclusion
Public APIs can still benefit from REST, but internal distributed systems need more:
Contracts that are strong
Payloads that are compact
Services that behave consistently
Streaming natively
Under load, scalability
In many modern enterprise platforms, gRPC has become the default choice.
One of the most practical upgrades you can make to your microservices in C# 14 + .NET 10 is to adopt gRPC for internal communication. You can find the code examples of this article on my GitHub Repository URL https://github.com/ziggyrafiq/csharp14-grpc-customer-microservice-demo