If your services talk to each other more than they talk to users, gRPC is usually a better fit than REST. It’s a contract-first RPC framework built on HTTP/2, using Protocol Buffers (or JSON if you must) for compact, strongly-typed messages. The result: lower latency, fewer bytes on the wire, first-class streaming, and generated clients that feel like calling local methods.
This post gives you a practical, C#-focused tour: when to choose gRPC, how to build/host a service in ASP.NET Core, how to consume it, how to stream, handle errors, and make it work with browsers via gRPC-Web.
TL;DR
- Use gRPC for service-to-service calls where you control both sides.
- Benefits: speed, streaming, strong contracts, codegen clients.
- Caveats: pure gRPC doesn’t work natively in browsers (use gRPC-Web), and debugging with plain
curlis harder than REST.
Why gRPC Beats REST for Microservices
- Compact binary payloads (protobuf) → faster and cheaper than JSON.
- HTTP/2 multiplexing → many simultaneous calls over one TCP connection.
- Streaming (server, client, bidirectional) → real-time updates without hacks.
- Generated clients → fewer hand-written DTOs/serializers; fewer runtime surprises.
- Contracts (
.protofiles) → versioning discipline and fewer breaking changes.
When is REST still fine?
- Public APIs for humans with curl/Postman.
- Browser-only clients (unless you add gRPC-Web).
- Simple CRUD or cache-friendly endpoints where CDNs shine.
Project Setup (ASP.NET Core, .NET)
Server packages (csproj):
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="x.y.z" />
<PackageReference Include="Google.Protobuf" Version="x.y.z" />
<PackageReference Include="Grpc.Tools" Version="x.y.z" PrivateAssets="All" />
<PackageReference Include="Grpc.HealthCheck" Version="x.y.z" />
<PackageReference Include="Grpc.AspNetCore.Server.Reflection" Version="x.y.z" />
<!-- For gRPC-Web support -->
<PackageReference Include="Grpc.AspNetCore.Web" Version="x.y.z" />
<!-- For JSON transcoding (optional REST bridge) -->
<PackageReference Include="Grpc.AspNetCore.JsonTranscoding" Version="x.y.z" />
</ItemGroup>
Client packages:
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="x.y.z" />
<PackageReference Include="Google.Protobuf" Version="x.y.z" />
</ItemGroup>
Replace
x.y.zwith the latest stable versions.
Define the Contract (.proto)
Create Protos/greeter.proto:
syntax = "proto3";
option csharp_namespace = "Demo.Grpc";
package demo;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
rpc PriceStream (PriceRequest) returns (stream PriceUpdate);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
message PriceRequest {
string symbol = 1; // e.g., "BTCUSD"
}
message PriceUpdate {
string symbol = 1;
double price = 2;
int64 unix_ms = 3;
}
Add to the server .csproj so tooling generates server stubs and shared models:
<ItemGroup>
<Protobuf Include="Protos\\greeter.proto" GrpcServices="Server" />
</ItemGroup>
Add to the client .csproj:
<ItemGroup>
<Protobuf Include="Protos\\greeter.proto" GrpcServices="Client" />
</ItemGroup>
Implement the Service (Server)
Services/GreeterService.cs:
using Demo.Grpc;
using Grpc.Core;
public class GreeterService : Greeter.GreeterBase
{
public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
var trailers = new Metadata { { "reason", "empty-name" } };
throw new RpcException(new Status(StatusCode.InvalidArgument, "Name is required"), trailers);
}
return Task.FromResult(new HelloReply { Message = $"Hello, {request.Name}!" });
}
public override async Task PriceStream(PriceRequest request, IServerStreamWriter<PriceUpdate> responseStream, ServerCallContext context)
{
var rnd = new Random();
var price = 100.0 + rnd.NextDouble() * 10; // mock price
while (!context.CancellationToken.IsCancellationRequested)
{
// Simulate price movement
price += (rnd.NextDouble() - 0.5) * 0.5;
await responseStream.WriteAsync(new PriceUpdate
{
Symbol = request.Symbol,
Price = Math.Round(price, 2),
UnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
await Task.Delay(500, context.CancellationToken);
}
}
}
Program.cs:
using Grpc.HealthCheck;
using Grpc.Health.V1;
var builder = WebApplication.CreateBuilder(args);
// Kestrel config: HTTP/2 for gRPC
builder.WebHost.ConfigureKestrel(o =>
{
// In production, expose HTTPS with a real certificate
o.ListenAnyIP(5000, lo => lo.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2);
});
// Services
builder.Services.AddGrpc();
builder.Services.AddGrpcHealthChecks()
.AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy());
#if DEBUG
builder.Services.AddGrpcReflection();
#endif
var app = builder.Build();
app.MapGrpcService<GreeterService>();
app.MapGrpcHealthChecksService();
// Optional: reflection enables grpcurl/grpcui in dev
#if DEBUG
app.MapGrpcReflectionService();
#endif
app.MapGet("/", () => "gRPC server running. Use a gRPC client to communicate.");
app.Run();
TLS note: In production, run gRPC over HTTPS (HTTP/2). Browsers and some proxies are picky; terminate TLS at the edge (e.g., Envoy or Traefik) or configure Kestrel with certificates.
Write a Client (Console)
Client/Program.cs:
using Demo.Grpc;
using Grpc.Core;
using Grpc.Net.Client;
using var channel = GrpcChannel.ForAddress("http://localhost:5000"); // use https in prod
var client = new Greeter.GreeterClient(channel);
// Unary
var hello = await client.SayHelloAsync(new HelloRequest { Name = "Leon" });
Console.WriteLine(hello.Message);
// Server streaming with deadline + metadata
var headers = new Metadata { { "x-client", "demo-console" } };
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // auto-cancel demo
var callOpts = new CallOptions(headers: headers, deadline: DateTime.UtcNow.AddSeconds(30), cancellationToken: cts.Token);
using var call = client.PriceStream(new PriceRequest { Symbol = "BTCUSD" }, callOpts);
try
{
await foreach (var update in call.ResponseStream.ReadAllAsync(cts.Token))
Console.WriteLine($"{update.Symbol} {update.Price} @ {update.UnixMs}");
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
{
Console.WriteLine("Price stream deadline exceeded.");
}
catch (OperationCanceledException)
{
Console.WriteLine("Stream canceled.");
}
Error Handling & Status Codes
Throw RpcException with a structured status and metadata:
if (string.IsNullOrWhiteSpace(request.Name))
{
var trailers = new Metadata { { "reason", "empty-name" } };
throw new RpcException(new Status(StatusCode.InvalidArgument, "Name is required"), trailers);
}
On the client, catch RpcException and branch by StatusCode (Unavailable, DeadlineExceeded, PermissionDenied, etc.).
Retries, Timeouts, and Backoff
Define retry policies per-method (client-side via service config):
using Grpc.Net.Client.Configuration;
var serviceConfig = new ServiceConfig
{
MethodConfigs =
{
new MethodConfig
{
Names = { MethodName<Demo.Grpc.Greeter, Demo.Grpc.HelloRequest>("SayHello") }, // helper omitted
RetryPolicy = new RetryPolicy
{
MaxAttempts = 4,
InitialBackoff = TimeSpan.FromMilliseconds(200),
MaxBackoff = TimeSpan.FromSeconds(2),
BackoffMultiplier = 2,
RetryableStatusCodes = { StatusCode.Unavailable, StatusCode.DeadlineExceeded }
},
Timeout = TimeSpan.FromSeconds(2)
}
}
};
using var channel = GrpcChannel.ForAddress("http://localhost:5000", new GrpcChannelOptions { ServiceConfig = serviceConfig });
Keep retries for idempotent methods. Always set deadlines to avoid hanging calls.
Observability: Health, Reflection, and Interceptors
- Health checks: already mapped above; can be probed by orchestration (Kubernetes, Docker healthcheck).
- Reflection: during development, use
grpcurl/grpcuito explore services without a proto file. - Interceptors: add cross-cutting behavior (logging, tracing, auth) without polluting handlers.
Example server interceptor skeleton:
using Grpc.Core;
using Grpc.Core.Interceptors;
public class LoggingInterceptor : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try { return await continuation(request, context); }
finally { Console.WriteLine($"{context.Method} took {sw.ElapsedMilliseconds}ms"); }
}
}
Register:
builder.Services.AddGrpc(o => o.Interceptors.Add<LoggingInterceptor>());
Making It Work in Browsers: gRPC‑Web & JSON Transcoding
Browsers don’t expose raw HTTP/2 request APIs, so pure gRPC won’t work directly. Two common solutions:
-
gRPC‑Web (HTTP/1.1/2 compatible):
- Server: enable middleware and mapping:
builder.Services.AddGrpc(); var app = builder.Build(); app.UseGrpcWeb(); // enable pipeline app.MapGrpcService<GreeterService>().EnableGrpcWeb(); // allow gRPC-Web - Client: use the official
grpc-webJavaScript client or Blazor WebAssembly withGrpc.Net.Client.Web.
- Server: enable middleware and mapping:
-
JSON Transcoding (REST facade to gRPC):
- Install
Grpc.AspNetCore.JsonTranscoding. - Annotate proto methods with HTTP options (requires
google/api/annotations.proto) and map REST paths to gRPC calls. - Great for public/browser clients while keeping a single source of truth in your
.proto.
- Install
Versioning Tips
- Add fields, don’t reuse numbers. Old clients ignore new fields; new clients still read old messages.
- Prefer optional fields over changing types.
- Avoid breaking renames in service/method names; add new methods and deprecate old ones.
Alternative in C#: Contract‑First Without .proto
If you want all‑C# contracts, protobuf‑net.Grpc lets you define interfaces and decorate messages with [ProtoContract]. It’s ergonomic in pure .NET shops, but you lose some cross‑language interoperability compared to shipping .proto files.
When NOT to Use gRPC
- Public APIs where curlability/docs/HTTP caching matter more than raw speed.
- Browser-only apps without a proxy (use gRPC‑Web or REST).
- Very simple endpoints that benefit from CDNs and long‑lived caching.
Production Checklist
- [ ] TLS enabled (HTTP/2 over HTTPS).
- [ ] Deadlines on every call.
- [ ] Retries only for idempotent methods.
- [ ] Health checks + metrics (OpenTelemetry/Prometheus).
- [ ] Centralized error mapping (
RpcExceptionwithStatusCode). - [ ] Backward‑compatible message evolution.
- [ ] For web clients: gRPC‑Web or JSON transcoding.
Closing Thoughts
For internal microservice calls in .NET, gRPC is usually the right default: it’s faster, safer, and easier to maintain as systems grow. Keep REST for human‑facing or public endpoints—and let gRPC handle the chatty, critical service‑to‑service paths.