Problem Context
gRPC is what you reach for when JSON-over-HTTP is too slow, too unstructured, or too one-directional. It's HTTP/2 (and now HTTP/3 in Grpc.Net.Clienton .NET 9) + Protocol Buffers + code-generated stubs in 12 languages. In 2026 it's the default for service-to-service traffic at most large shops, with gRPC-Web bridging browsers, Connect-RPCoffering a friendlier wire format, and gRPC over HTTP/3 available in production.
The trade is real: gRPC is ~5-10x faster on the wire than JSON and supports streaming both ways, but you give up cache-friendly semantics, browser-native debugging, and easy curl. Use it inside the mesh; expose REST or GraphQL at the edge.
- Your service-to-service JSON costs more CPU than the business logic
- You wrote the same DTO five times in five languages
- You need bidirectional streaming and SignalR feels heavy
- Your mobile team wants strongly-typed clients without OpenAPI codegen pain
Define once in .proto, generate everywhere, stream when you need to, fall back to JSON transcoding for the edge.
Concept Explanation
Four call patterns:
- Unary โ request/response. The 90% case.
- Server streaming โ one request, many responses (live feed, paged export).
- Client streaming โ many requests, one response (file upload, batch ingest).
- Bidirectional streaming โ full-duplex (chat, telemetry, collaboration).
flowchart LR
Browser["Browser<br/>(JS / Blazor)"] -->|gRPC-Web<br/>HTTP/1.1+Base64| Envoy["Envoy / YARP"]
Envoy -->|gRPC HTTP/2| API["Public API Gateway"]
API -->|gRPC HTTP/2| S1["Orders service"]
API -->|gRPC HTTP/2| S2["Inventory service"]
Mobile["Mobile (native gRPC)"] -->|HTTP/2 or HTTP/3| API
style API fill:#0078D4,color:#fff,stroke:#005a9e
Implementation
Step 1: Define the contract in .proto
// File: Protos/orders.proto
syntax = "proto3";
option csharp_namespace = "Orders";
package orders;
service Orders {
rpc GetOrder (GetOrderRequest) returns (Order);
rpc StreamUpdates (StreamRequest) returns (stream OrderEvent); // server stream
rpc IngestBatch (stream OrderInput) returns (BatchAck); // client stream
rpc Chat (stream ChatMessage) returns (stream ChatMessage); // bidi
}
message GetOrderRequest { int64 id = 1; }
message Order { int64 id = 1; string customer = 2; int64 total_cents = 3; }
message StreamRequest { int64 since_id = 1; }
message OrderEvent { int64 id = 1; string kind = 2; }
message OrderInput { string customer = 1; int64 total_cents = 2; }
message BatchAck { int32 accepted = 1; int32 rejected = 2; }
message ChatMessage { string user = 1; string body = 2; }Step 2: Server in .NET 9
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc(o => o.EnableDetailedErrors = false);
builder.Services.AddGrpcReflection(); // for grpcurl
var app = builder.Build();
app.MapGrpcService<OrdersServiceImpl>();
app.MapGrpcReflectionService();
app.Run();
public class OrdersServiceImpl : Orders.OrdersBase
{
public override async Task<Order> GetOrder(GetOrderRequest req, ServerCallContext ctx)
{
var o = await db.Orders.FindAsync(req.Id, ctx.CancellationToken);
if (o is null)
throw new RpcException(new Status(StatusCode.NotFound, $"order {req.Id}"));
return new Order { Id = o.Id, Customer = o.Customer, TotalCents = o.TotalCents };
}
public override async Task StreamUpdates(
StreamRequest req, IServerStreamWriter<OrderEvent> writer, ServerCallContext ctx)
{
await foreach (var ev in events.SubscribeAsync(req.SinceId, ctx.CancellationToken))
await writer.WriteAsync(ev);
}
}Step 3: Strongly-typed client + DI
// Program.cs (consumer)
builder.Services.AddGrpcClient<Orders.OrdersClient>(o =>
{
o.Address = new Uri("https://orders.internal");
})
.ConfigureChannel(c =>
{
c.HttpVersion = HttpVersion.Version30; // HTTP/3 on .NET 9
c.HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
c.MaxRetryAttempts = 5;
})
.AddPolicyHandler(...); // Polly resilience
// Inject + call
public class OrdersFacade(Orders.OrdersClient client)
{
public Task<Order> GetAsync(long id, CancellationToken ct) =>
client.GetOrderAsync(new GetOrderRequest { Id = id }, cancellationToken: ct).ResponseAsync;
}Step 4: Deadlines, cancellation, retries
// Always pass a deadline โ gRPC defaults to infinite
var resp = await client.GetOrderAsync(
new GetOrderRequest { Id = 42 },
deadline: DateTime.UtcNow.AddSeconds(2),
cancellationToken: ct);
// Service config for transparent retries on UNAVAILABLE / DEADLINE_EXCEEDED
var channel = GrpcChannel.ForAddress("https://orders.internal", new GrpcChannelOptions
{
ServiceConfig = new ServiceConfig
{
MethodConfigs = { new MethodConfig {
Names = { MethodName.Default },
RetryPolicy = new RetryPolicy {
MaxAttempts = 5,
InitialBackoff = TimeSpan.FromMilliseconds(100),
MaxBackoff = TimeSpan.FromSeconds(2),
BackoffMultiplier = 2,
RetryableStatusCodes = { StatusCode.Unavailable, StatusCode.DeadlineExceeded }
}
}}
}
});Step 5: gRPC-Web for browser clients
// Server: enable gRPC-Web on the same endpoints
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
app.MapGrpcService<OrdersServiceImpl>().EnableGrpcWeb();
// Client (TypeScript / Connect-Web): generated TS stubs hit the same URL.
// Streaming is server-only over gRPC-Web; for true bidi in browsers,
// use WebTransport or fall back to SignalR.Step 6: JSON transcoding (gRPC + REST in one)
// .NET 9: Microsoft.AspNetCore.Grpc.JsonTranscoding
builder.Services.AddGrpc().AddJsonTranscoding();
// Annotate .proto with google.api.http
// rpc GetOrder(GetOrderRequest) returns (Order) {
// option (google.api.http) = { get: "/v1/orders/{id}" };
// }
// Same service now responds to grpc AND GET /v1/orders/123 with JSON.
// Combine with .NET 9 OpenAPI for a Swagger UI on the gRPC service.Common Pitfalls
- No deadlines. gRPC defaults to infinite. One slow downstream and your thread pool is gone. Always pass
deadline:at the call site. - Reusing message types as DTOs.Generated types are wire types. Map them at the boundary so renaming a domain field doesn't break the contract.
- Renumbering fields. Proto field numbers are forever. Add new fields with new numbers; never reuse a number.
- Calling gRPC from a browser without gRPC-Web.Native gRPC needs HTTP/2 trailers, which browsers can't access. Use gRPC-Web or Connect.
- Treating streams like long unary calls. A streaming RPC that runs for hours holds a TCP connection, a thread, and back-pressure budget. Add heartbeats; resume from a sequence number.
- Forgetting Kestrel HTTP/2 + ALPN config behind a load balancer. If your LB terminates HTTP/1.1, gRPC dies silently. Keep the path end-to-end HTTP/2 or HTTP/3.
Practical Takeaways
- gRPC inside the mesh, REST/GraphQL at the edge. That's the 2026 default for most teams.
- .proto is the contract. Generate clients in every language; never hand-code DTOs.
- Always set a deadline. Always.
- HTTP/3 client transport is shipping in .NET 9 โ try it for high-latency paths.
- JSON transcoding lets one service answer both gRPC and REST without writing two endpoints.
- Field numbers are forever. Treat
.protochanges like database migrations. - For browser duplex, gRPC-Web (server-stream only) + WebSockets / WebTransport is more honest than promising bidi gRPC.

