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.

๐Ÿค” Sound familiar?
  • 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

  1. No deadlines. gRPC defaults to infinite. One slow downstream and your thread pool is gone. Always passdeadline: at the call site.
  2. 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.
  3. Renumbering fields. Proto field numbers are forever. Add new fields with new numbers; never reuse a number.
  4. Calling gRPC from a browser without gRPC-Web.Native gRPC needs HTTP/2 trailers, which browsers can't access. Use gRPC-Web or Connect.
  5. 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.
  6. 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 .proto changes like database migrations.
  • For browser duplex, gRPC-Web (server-stream only) + WebSockets / WebTransport is more honest than promising bidi gRPC.