Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 064 / 100

gRPC in .NET: concepts, trade-offs and production lessons

Fast, typed service-to-service calls.

Expona Admin7 min read

Introduction

When services talk to each other across a network, every millisecond counts. gRPC in .NET: concepts, trade-offs and production lessons is essential knowledge for any team building high-performance microservices. Unlike REST, which sends text-based JSON over HTTP/1.1, gRPC uses binary Protobuf messages over HTTP/2 to enable fast, typed service-to-service communication that scales with your infrastructure.

In this article, we walk through what makes gRPC practical in production environments, when to reach for it instead of REST, and the gotchas we've learned the hard way across real e-commerce, trading, and SaaS systems.

Contract First: Define Services in .proto Files

gRPC starts with a contract. You define your service interface and message shapes in a .proto file—Protocol Buffer format—and then the toolchain generates strongly-typed client and server code. This contract-first approach removes ambiguity between teams and eliminates the runtime discovery friction you often see with REST APIs.

Here's a simple but realistic example. In an e-commerce checkout flow, the order service needs to reserve inventory:

csharp
syntax = "proto3";

package inventory.v1;

service Inventory {
  rpc Reserve (ReserveRequest) returns (ReserveReply);
  rpc Release (ReleaseRequest) returns (ReleaseReply);
}

message ReserveRequest {
  string order_id = 1;
  repeated LineItem items = 2;
  int32 duration_seconds = 3;
}

message LineItem {
  string sku = 1;
  int32 quantity = 2;
}

message ReserveReply {
  bool success = 1;
  string reservation_id = 2;
  string error_message = 3;
}

message ReleaseRequest {
  string reservation_id = 1;
}

message ReleaseReply {
  bool success = 1;
}

Once you check this file into your repository, running the protoc compiler generates C# classes and a gRPC client stub. Your order service then uses that generated client with full compile-time safety:

csharp
var channel = GrpcChannel.ForAddress("https://inventory-service:5001");
var client = new Inventory.InventoryClient(channel);

var request = new ReserveRequest
{
    OrderId = orderId,
    DurationSeconds = 300
};
request.Items.Add(new LineItem { Sku = "SKU-123", Quantity = 2 });

var reply = await client.ReserveAsync(request);
if (reply.Success)
{
    // Store reservation_id for later release
    order.ReservationId = reply.ReservationId;
}
else
{
    throw new InvalidOperationException(reply.ErrorMessage);
}

The contract is version-controlled, reviewed like code, and backward compatibility is explicit. When the inventory team adds a new field to the response (like available_count), your existing client doesn't break—that's by design in Protobuf.

Why gRPC Is Fast: HTTP/2, Binary Encoding, and Multiplexing

gRPC's performance advantage comes from three technical foundations that work together.

HTTP/2: Unlike HTTP/1.1 where each request opens a new connection and waits for a response, HTTP/2 multiplexes many streams over a single connection. No more head-of-line blocking. A single TCP connection can handle hundreds of concurrent requests.

Binary Protobuf: JSON is human-readable but verbose. Protobuf is a binary format that encodes the same data in roughly 30–50% less space. A typical gRPC message weighs far less on the wire, reducing bandwidth and speeding up serialization and deserialization.

Multiplexed Streams: Each RPC call is a stream within the same connection. This means low latency for sequential calls and natural backpressure handling when the server is under load.

In one ride-hailing platform we worked on, switching location tracking from REST polling to gRPC streaming reduced payload size by 60% and cut P95 latency from 180ms to 45ms. The connection reuse alone meant fewer SSL handshakes and smoother tail latencies during rush hour.

csharp
// Benchmark comparison (rough order of magnitude)
// REST + JSON: ~500 bytes per ride update
// gRPC + Protobuf: ~180 bytes per ride update

// HTTP/1.1: new connection per request, SSL handshake overhead
// HTTP/2: single connection, multiplexed streams, connection reuse

// Real-world: 10,000 concurrent location updates
// REST: bottleneck at connection pool, ~400ms median latency
// gRPC: smooth handling, ~50ms median latency

Streaming: Server, Client, and Bidirectional

REST gives you request-response. gRPC gives you streams, which is a game-changer for live data.

Server streaming: The client makes one request and receives a stream of responses. Useful for paginating large datasets or broadcasting updates.

csharp
// In your .proto file:
// rpc ListOrderUpdates(ListRequest) returns (stream OrderUpdate);

// On the server:
public override async Task ListOrderUpdates(
    ListRequest request,
    IServerStreamWriter responseStream,
    ServerCallContext context)
{
    var orders = await _db.GetOrdersAsync(request.UserId);
    foreach (var order in orders)
    {
        await responseStream.WriteAsync(new OrderUpdate
        {
            OrderId = order.Id,
            Status = order.Status,
            UpdatedAt = Timestamp.FromDateTime(order.UpdatedAt)
        });
    }
}

// On the client:
using var call = client.ListOrderUpdates(new ListRequest { UserId = userId });
await foreach (var update in call.ResponseStream.ReadAllAsync())
{
    Console.WriteLine($"Order {update.OrderId}: {update.Status}");
}

Client streaming: The client sends a stream of requests and receives a single response. Ideal for bulk uploads or sensor data batching.

csharp
// rpc BatchReserve(stream ReserveRequest) returns (BatchReply);

var call = client.BatchReserve();
foreach (var reservation in reservationsToMake)
{
    await call.RequestStream.WriteAsync(new ReserveRequest { /* ... */ });
}
await call.RequestStream.CompleteAsync();
var reply = await call.ResponseAsync;

Bidirectional streaming: Both sides send and receive asynchronously. Perfect for real-time chat, live dashboards, or collaborative editing.

csharp
// rpc SyncInventory(stream InventoryEvent) returns (stream InventoryEvent);

var call = client.SyncInventory();

// Send updates in the background
_ = Task.Run(async () =>
{
    while (!context.CancellationToken.IsCancellationRequested)
    {
        var update = await _inventoryQueue.DequeueAsync();
        await call.RequestStream.WriteAsync(update);
    }
});

// Receive updates concurrently
await foreach (var remoteUpdate in call.ResponseStream.ReadAllAsync())
{
    await ProcessRemoteInventoryUpdate(remoteUpdate);
}

In a trading platform we maintained, bidirectional streaming for market data feeds cut latency variance dramatically. Clients no longer poll; they get pushed updates as they arrive, with backpressure handled by gRPC's flow control.

Where REST Still Wins: Public APIs and Browser Clients

gRPC is powerful, but it's not a universal replacement for REST. Browser clients cannot make gRPC calls directly (no native HTTP/2 support in older browsers, and gRPC-web adds complexity). Public APIs also benefit from REST's simplicity and discoverability—curl commands, OpenAPI specs, and the HTTP verb semantics are well-understood.

Our rule of thumb: use gRPC for internal service-to-service communication. Use REST (or GraphQL) for public-facing APIs and anything consumed by browsers. This is where we've seen the fewest surprises.

Scenario gRPC REST
Internal microservice calls ✓ Preferred Works, but slower
Public API Complex setup ✓ Standard
Browser client Requires gRPC-web ✓ Native
Live streaming data ✓ Natural Long-polling, WebSocket workarounds
Bulk data transfer ✓ Efficient Works, larger payloads

Common Mistakes

  • Premature adoption: gRPC adds operational overhead. Start with REST; move to gRPC only when latency or bandwidth is a measurable bottleneck.
  • Ignoring load balancing: gRPC's connection pooling can lead to uneven load across server replicas if you don't configure client-side or server-side load balancing correctly. Use round_robin policy in your channel config.
  • Forgetting deadline propagation: Set deadlines on your RPC calls. Without them, slow downstream services can starve your caller's resources.
  • Breaking backward compatibility: Protobuf is forgiving, but only if you follow the rules. Never reuse field numbers, and always add new fields at the end. One slip and you'll corrupt data.
  • Not testing streaming cancellation: Streaming calls need explicit cancellation handling. Test what happens when the client hangs up mid-stream; your server cleanup code must be bulletproof.
  • Neglecting observability: gRPC calls are opaque to traditional HTTP monitoring. Invest in gRPC interceptors for logging, tracing, and metrics from the start.

Key Takeaways

  • gRPC uses .proto contracts to generate strongly-typed client and server code, eliminating runtime API discovery friction.
  • HTTP/2 multiplexing, binary Protobuf encoding, and connection reuse deliver 2–10× lower latency and bandwidth for internal service calls compared to REST.
  • Server, client, and bidirectional streaming are natural and efficient in gRPC, replacing REST polling patterns.
  • gRPC shines in microservice architectures; REST remains the standard for public APIs and browser clients.
  • Configure load balancing policies, set explicit deadlines, test streaming cancellation, and add observability interceptors early.
  • Backward compatibility is automatic in Protobuf only if you respect field numbering and append-only evolution rules.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles