Spors Logo
SPORS Blog
Code · Tech · Development
What Is gRPC — and Why It’s Replacing REST in Microservices (with C# Examples)
development

What Is gRPC — and Why It’s Replacing REST in Microservices (with C# Examples)

September 22, 2025

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 curl is harder than REST.

Why gRPC Beats REST for Microservices

  1. Compact binary payloads (protobuf) → faster and cheaper than JSON.
  2. HTTP/2 multiplexing → many simultaneous calls over one TCP connection.
  3. Streaming (server, client, bidirectional) → real-time updates without hacks.
  4. Generated clients → fewer hand-written DTOs/serializers; fewer runtime surprises.
  5. Contracts (.proto files) → 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.z with 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/grpcui to 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:

  1. 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-web JavaScript client or Blazor WebAssembly with Grpc.Net.Client.Web.
  2. 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.

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 (RpcException with StatusCode).
  • [ ] 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.