
Learn gRPC in C# .NET with Protocol Buffers. Build fast, type-safe APIs with streaming, deadlines and interceptors. Start the full tutorial now.
If you have ever watched a JSON REST API buckle under chatty microservice traffic, gRPC in C# is the fix you have been looking for. gRPC is a contract-first RPC framework built on HTTP/2 and Protocol Buffers, and the .NET implementation is one of the fastest in the entire gRPC ecosystem. Instead of hand-writing controllers, DTOs and HttpClient wrappers, you describe your service once in a .proto file and the toolchain generates strongly typed server bases and clients for you.
This guide walks through building a production-grade gRPC service in ASP.NET Core: the protobuf contract, the server, the client, all four call types, deadlines, interceptors, retries, JSON transcoding, and the pitfalls that bite teams in month three. Every example runs on .NET 8 or later (the snippets below were verified against .NET 10).
Why Choose gRPC in C# Over REST?
REST over JSON is fine for public, browser-facing APIs. It is a poor fit for service-to-service traffic, and the reasons are mechanical rather than ideological:
- Binary serialization. Protocol Buffers encode field numbers as varints instead of repeating string keys on every message. Payloads are typically 30–60% smaller than equivalent JSON, and parsing avoids string allocation entirely.
- HTTP/2 multiplexing. Many concurrent calls share one TCP connection with no head-of-line blocking at the HTTP layer, plus HPACK header compression. REST over HTTP/1.1 opens a connection pool and re-sends the same headers thousands of times.
- A real contract. The
.protofile is the single source of truth. Client and server are generated from it, so a renamed field is a compile error rather than a 2 a.m. null reference. - First-class streaming. Server, client and bidirectional streaming are part of the protocol, not a bolt-on like SSE or a separate WebSocket stack.
- Polyglot by default. The same contract generates Go, Java, Python, Node and Rust clients — useful when your C# service is one node in a mixed fleet.
The honest trade-off: gRPC is not directly callable from a browser (browsers cannot control HTTP/2 frames), and binary payloads are harder to eyeball with curl. We cover both mitigations — gRPC-Web and JSON transcoding — later in this article.
Defining the Contract with Protocol Buffers
Create a new project with dotnet new grpc -n OrderService. The template wires up the Grpc.AspNetCore package and a sample .proto. Replace it with a realistic contract:
// Protos/orders.proto
syntax = "proto3";
option csharp_namespace = "OrderService.Protos";
package orders;
import "google/protobuf/timestamp.proto";
service Orders {
rpc GetOrder (GetOrderRequest) returns (OrderReply);
rpc StreamOrderUpdates (GetOrderRequest) returns (stream OrderReply);
rpc BulkCreate (stream CreateOrderRequest) returns (BulkCreateReply);
rpc Chat (stream OrderNote) returns (stream OrderNote);
}
message GetOrderRequest {
string order_id = 1;
}
message CreateOrderRequest {
string customer_id = 1;
string sku = 2;
int32 quantity = 3;
}
message OrderReply {
string order_id = 1;
string customer_id = 2;
OrderStatus status = 3;
double total = 4;
google.protobuf.Timestamp created_at = 5;
}
message BulkCreateReply {
int32 created = 1;
repeated string failed_skus = 2;
}
message OrderNote {
string order_id = 1;
string text = 2;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_SHIPPED = 2;
ORDER_STATUS_CANCELLED = 3;
}
Three details in that file matter more than they look. Field numbers are the wire identity — never reuse or renumber them, because old clients will misread the bytes. Numbers 1–15 cost a single byte of tag overhead, so spend them on your hottest fields. And every proto3 enum must have a zero value; naming it *_UNSPECIFIED is the Google style guide convention that saves you from mistaking "not set" for a real state.
Register the file for code generation in your .csproj:
<ItemGroup>
<Protobuf Include="Protos\orders.proto" GrpcServices="Server" />
</ItemGroup>
Use GrpcServices="Client" in consumer projects and "Both" in a shared contracts library. Publishing that contracts project as an internal NuGet package is the cleanest way to version a contract across teams.
Implementing the gRPC Service in ASP.NET Core
The generator produces an abstract Orders.OrdersBase class. Override the methods you care about:
using Grpc.Core;
using Google.Protobuf.WellKnownTypes;
using OrderService.Protos;
public sealed class OrdersService : Orders.OrdersBase
{
private readonly IOrderRepository _repository;
private readonly ILogger<OrdersService> _logger;
public OrdersService(IOrderRepository repository, ILogger<OrdersService> logger)
{
_repository = repository;
_logger = logger;
}
public override async Task<OrderReply> GetOrder(
GetOrderRequest request, ServerCallContext context)
{
var order = await _repository.FindAsync(request.OrderId, context.CancellationToken);
if (order is null)
{
throw new RpcException(new Status(
StatusCode.NotFound, $"Order '{request.OrderId}' was not found."));
}
return new OrderReply
{
OrderId = order.Id,
CustomerId = order.CustomerId,
Status = OrderStatus.OrderStatusShipped,
Total = order.Total,
CreatedAt = Timestamp.FromDateTimeOffset(order.CreatedAt)
};
}
}
Note two things. Errors travel as RpcException with a gRPC StatusCode — the equivalent of choosing an HTTP status code, and the client can branch on it reliably. And context.CancellationToken is already wired to client disconnects and deadlines; pass it all the way down to EF Core and HttpClient or you will keep burning CPU on work nobody is waiting for.
Wire it up in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4 MB
options.Interceptors.Add<ServerLoggingInterceptor>();
});
builder.Services.AddGrpcReflection();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
var app = builder.Build();
app.MapGrpcService<OrdersService>();
if (app.Environment.IsDevelopment())
{
app.MapGrpcReflectionService(); // lets grpcurl / Postman discover your API
}
app.Run();
Keep EnableDetailedErrors off in production — it returns exception messages and stack details to callers. Enable reflection in development only, for the same reason you would not ship Swagger UI to an unauthenticated production endpoint.
Calling the Service: The gRPC C# Client
For anything beyond a console demo, use Grpc.Net.ClientFactory so channels are pooled, handlers are rotated correctly, and dependency injection works:
builder.Services
.AddGrpcClient<Orders.OrdersClient>(o =>
{
o.Address = new Uri("https://orders.internal:5001");
})
.ConfigureChannel(channel =>
{
channel.ServiceConfig = new ServiceConfig
{
MethodConfigs =
{
new MethodConfig
{
Names = { MethodName.Default },
RetryPolicy = new RetryPolicy
{
MaxAttempts = 4,
InitialBackoff = TimeSpan.FromMilliseconds(200),
MaxBackoff = TimeSpan.FromSeconds(3),
BackoffMultiplier = 2,
RetryableStatusCodes = { StatusCode.Unavailable }
}
}
}
};
});
Then inject and call it. Always set a deadline:
public sealed class OrderLookup(Orders.OrdersClient client)
{
public async Task<OrderReply?> TryGetAsync(string id, CancellationToken ct)
{
try
{
return await client.GetOrderAsync(
new GetOrderRequest { OrderId = id },
deadline: DateTime.UtcNow.AddSeconds(5),
cancellationToken: ct);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
return null;
}
}
}
Deadlines are the single highest-value habit in gRPC. Unlike an HTTP timeout, a deadline propagates: the server sees it in ServerCallContext.Deadline, cancels its own token when it expires, and passes it to downstream gRPC calls. That is what stops one slow database from cascading into a fleet-wide thread pile-up.
Streaming: Where gRPC Really Pulls Ahead
Server streaming replaces polling loops. The method receives an IServerStreamWriter and writes as many messages as it likes:
public override async Task StreamOrderUpdates(
GetOrderRequest request,
IServerStreamWriter<OrderReply> responseStream,
ServerCallContext context)
{
await foreach (var update in _repository.WatchAsync(request.OrderId, context.CancellationToken))
{
await responseStream.WriteAsync(new OrderReply
{
OrderId = update.Id,
Status = update.Status,
Total = update.Total
}, context.CancellationToken);
}
}
Client streaming inverts it — useful for bulk ingestion without a giant single payload:
public override async Task<BulkCreateReply> BulkCreate(
IAsyncStreamReader<CreateOrderRequest> requestStream,
ServerCallContext context)
{
var reply = new BulkCreateReply();
await foreach (var request in requestStream.ReadAllAsync(context.CancellationToken))
{
if (await _repository.CreateAsync(request, context.CancellationToken))
reply.Created++;
else
reply.FailedSkus.Add(request.Sku);
}
return reply;
}
Consuming a server stream from the client is just an await foreach:
using var call = client.StreamOrderUpdates(new GetOrderRequest { OrderId = id });
await foreach (var update in call.ResponseStream.ReadAllAsync(ct))
{
Console.WriteLine($"{update.OrderId} -> {update.Status}");
}
Streaming calls are long-lived, so do not apply a short deadline to them, and always dispose the call (the using above) to release the HTTP/2 stream.
Cross-Cutting Concerns: Interceptors
Interceptors are gRPC's middleware. Use them for logging, correlation IDs, metrics and exception mapping instead of repeating try/catch in every method:
public sealed class ServerLoggingInterceptor(ILogger<ServerLoggingInterceptor> logger)
: Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var start = Stopwatch.GetTimestamp();
try
{
return await continuation(request, context);
}
catch (Exception ex) when (ex is not RpcException)
{
logger.LogError(ex, "Unhandled error in {Method}", context.Method);
throw new RpcException(new Status(StatusCode.Internal, "Internal error."));
}
finally
{
logger.LogInformation("{Method} took {Elapsed}ms",
context.Method, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
}
}
}
Authentication works exactly as it does in the rest of ASP.NET Core: add JWT bearer auth, call app.UseAuthentication() / app.UseAuthorization(), and decorate services or methods with [Authorize]. On the client, attach the token with a CallCredentials callback so it is refreshed per call rather than captured once.
Best Practices and Common Pitfalls
- Reuse channels. A
GrpcChannelis expensive and thread-safe. Creating one per request destroys the performance benefit and can exhaust sockets. Use the client factory. - Never renumber or reuse a field. Delete a field and mark it
reserved 4;so nobody recycles the number later. - Watch the 100-stream limit. HTTP/2 connections cap concurrent streams (typically 100). Beyond that, calls queue invisibly. Set
SocketsHttpHandler.EnableMultipleHttp2Connections = trueon high-throughput clients. - Prefer
google.protobuf.TimestampandDurationover raw strings or ticks — they are UTC-normalized and understood by every language. - Use wrapper types for true nullability. A proto3
int32defaults to 0, which is indistinguishable from "not sent". Usegoogle.protobuf.Int32Valueor theoptionalkeyword when the difference matters. - Do not stream tiny messages one at a time. Each message has framing overhead; batch 100–1,000 items per message for bulk transfer.
- Plan for browsers. Add the
Grpc.AspNetCore.Webpackage andapp.UseGrpcWeb()for gRPC-Web, or addMicrosoft.AspNetCore.Grpc.JsonTranscodingto expose the same service as a REST/JSON endpoint from HTTP annotations in the proto — one implementation, two protocols. - TLS is effectively mandatory. HTTP/2 without TLS requires explicit configuration on both ends; in production, just use HTTPS.
- Add health checks.
Grpc.AspNetCore.HealthChecksimplements the standard health protocol that Kubernetes and load balancers already understand.
Advanced gRPC in C#: Load Balancing and Code-First
Two capabilities are worth knowing once you are past the basics. First, client-side load balancing via Grpc.Net.Client.Balancer lets a channel resolve multiple backend addresses (static, DNS, or a custom resolver) and spread calls with a round-robin policy — important because an L4 proxy will otherwise pin every request from a long-lived HTTP/2 connection to a single pod.
Second, if a .proto file feels like ceremony for an all-.NET system, protobuf-net.Grpc offers a code-first model where you declare the contract with C# interfaces and attributes. You keep the wire format and performance, and you give up the polyglot generation story. Choose proto-first when other languages or other teams consume the API; code-first when they never will.
Key Takeaways
Adopting gRPC in C# is less about raw benchmark numbers and more about removing whole categories of integration bugs. You get a versioned contract, generated clients, compile-time safety, deadline propagation and native streaming — all on infrastructure that ASP.NET Core already optimizes hard.
- Define contracts in
.protofiles and treat field numbers as permanent. - Use
AddGrpcClientso channels are reused and retry policies are centralized. - Set a deadline on every unary call and honor
ServerCallContext.CancellationTokenend to end. - Reach for streaming instead of polling; batch small messages.
- Put logging, auth mapping and error translation in interceptors, not in each method.
- Expose gRPC-Web or JSON transcoding when browsers or curl-based consumers need access.
Start with one chatty internal endpoint, measure the payload size and p99 latency before and after, and let the numbers make the case for the rest of your fleet.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment