Skip to main content

gRPC in C# .NET: Build High-Performance APIs (2026 Guide)

Learn gRPC in C# .NET with Protocol Buffers. Build fast, type-safe APIs with streaming, deadlines and interceptors. Start the full tutorial now.

If you have ever watched a JSON REST API buckle under chatty microservice traffic, gRPC in C# is the fix you have been looking for. gRPC is a contract-first RPC framework built on HTTP/2 and Protocol Buffers, and the .NET implementation is one of the fastest in the entire gRPC ecosystem. Instead of hand-writing controllers, DTOs and HttpClient wrappers, you describe your service once in a .proto file and the toolchain generates strongly typed server bases and clients for you.

This guide walks through building a production-grade gRPC service in ASP.NET Core: the protobuf contract, the server, the client, all four call types, deadlines, interceptors, retries, JSON transcoding, and the pitfalls that bite teams in month three. Every example runs on .NET 8 or later (the snippets below were verified against .NET 10).

Why Choose gRPC in C# Over REST?

REST over JSON is fine for public, browser-facing APIs. It is a poor fit for service-to-service traffic, and the reasons are mechanical rather than ideological:

  • Binary serialization. Protocol Buffers encode field numbers as varints instead of repeating string keys on every message. Payloads are typically 30–60% smaller than equivalent JSON, and parsing avoids string allocation entirely.
  • HTTP/2 multiplexing. Many concurrent calls share one TCP connection with no head-of-line blocking at the HTTP layer, plus HPACK header compression. REST over HTTP/1.1 opens a connection pool and re-sends the same headers thousands of times.
  • A real contract. The .proto file is the single source of truth. Client and server are generated from it, so a renamed field is a compile error rather than a 2 a.m. null reference.
  • First-class streaming. Server, client and bidirectional streaming are part of the protocol, not a bolt-on like SSE or a separate WebSocket stack.
  • Polyglot by default. The same contract generates Go, Java, Python, Node and Rust clients — useful when your C# service is one node in a mixed fleet.

The honest trade-off: gRPC is not directly callable from a browser (browsers cannot control HTTP/2 frames), and binary payloads are harder to eyeball with curl. We cover both mitigations — gRPC-Web and JSON transcoding — later in this article.

Defining the Contract with Protocol Buffers

Create a new project with dotnet new grpc -n OrderService. The template wires up the Grpc.AspNetCore package and a sample .proto. Replace it with a realistic contract:

// Protos/orders.proto
syntax = "proto3";

option csharp_namespace = "OrderService.Protos";

package orders;

import "google/protobuf/timestamp.proto";

service Orders {
  rpc GetOrder (GetOrderRequest) returns (OrderReply);
  rpc StreamOrderUpdates (GetOrderRequest) returns (stream OrderReply);
  rpc BulkCreate (stream CreateOrderRequest) returns (BulkCreateReply);
  rpc Chat (stream OrderNote) returns (stream OrderNote);
}

message GetOrderRequest {
  string order_id = 1;
}

message CreateOrderRequest {
  string customer_id = 1;
  string sku = 2;
  int32 quantity = 3;
}

message OrderReply {
  string order_id = 1;
  string customer_id = 2;
  OrderStatus status = 3;
  double total = 4;
  google.protobuf.Timestamp created_at = 5;
}

message BulkCreateReply {
  int32 created = 1;
  repeated string failed_skus = 2;
}

message OrderNote {
  string order_id = 1;
  string text = 2;
}

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_SHIPPED = 2;
  ORDER_STATUS_CANCELLED = 3;
}

Three details in that file matter more than they look. Field numbers are the wire identity — never reuse or renumber them, because old clients will misread the bytes. Numbers 1–15 cost a single byte of tag overhead, so spend them on your hottest fields. And every proto3 enum must have a zero value; naming it *_UNSPECIFIED is the Google style guide convention that saves you from mistaking "not set" for a real state.

Register the file for code generation in your .csproj:

<ItemGroup>
  <Protobuf Include="Protos\orders.proto" GrpcServices="Server" />
</ItemGroup>

Use GrpcServices="Client" in consumer projects and "Both" in a shared contracts library. Publishing that contracts project as an internal NuGet package is the cleanest way to version a contract across teams.

Implementing the gRPC Service in ASP.NET Core

The generator produces an abstract Orders.OrdersBase class. Override the methods you care about:

using Grpc.Core;
using Google.Protobuf.WellKnownTypes;
using OrderService.Protos;

public sealed class OrdersService : Orders.OrdersBase
{
    private readonly IOrderRepository _repository;
    private readonly ILogger<OrdersService> _logger;

    public OrdersService(IOrderRepository repository, ILogger<OrdersService> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public override async Task<OrderReply> GetOrder(
        GetOrderRequest request, ServerCallContext context)
    {
        var order = await _repository.FindAsync(request.OrderId, context.CancellationToken);

        if (order is null)
        {
            throw new RpcException(new Status(
                StatusCode.NotFound, $"Order '{request.OrderId}' was not found."));
        }

        return new OrderReply
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            Status = OrderStatus.OrderStatusShipped,
            Total = order.Total,
            CreatedAt = Timestamp.FromDateTimeOffset(order.CreatedAt)
        };
    }
}

Note two things. Errors travel as RpcException with a gRPC StatusCode — the equivalent of choosing an HTTP status code, and the client can branch on it reliably. And context.CancellationToken is already wired to client disconnects and deadlines; pass it all the way down to EF Core and HttpClient or you will keep burning CPU on work nobody is waiting for.

Wire it up in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddGrpc(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
    options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4 MB
    options.Interceptors.Add<ServerLoggingInterceptor>();
});

builder.Services.AddGrpcReflection();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

var app = builder.Build();

app.MapGrpcService<OrdersService>();

if (app.Environment.IsDevelopment())
{
    app.MapGrpcReflectionService(); // lets grpcurl / Postman discover your API
}

app.Run();

Keep EnableDetailedErrors off in production — it returns exception messages and stack details to callers. Enable reflection in development only, for the same reason you would not ship Swagger UI to an unauthenticated production endpoint.

Calling the Service: The gRPC C# Client

For anything beyond a console demo, use Grpc.Net.ClientFactory so channels are pooled, handlers are rotated correctly, and dependency injection works:

builder.Services
    .AddGrpcClient<Orders.OrdersClient>(o =>
    {
        o.Address = new Uri("https://orders.internal:5001");
    })
    .ConfigureChannel(channel =>
    {
        channel.ServiceConfig = new ServiceConfig
        {
            MethodConfigs =
            {
                new MethodConfig
                {
                    Names = { MethodName.Default },
                    RetryPolicy = new RetryPolicy
                    {
                        MaxAttempts = 4,
                        InitialBackoff = TimeSpan.FromMilliseconds(200),
                        MaxBackoff = TimeSpan.FromSeconds(3),
                        BackoffMultiplier = 2,
                        RetryableStatusCodes = { StatusCode.Unavailable }
                    }
                }
            }
        };
    });

Then inject and call it. Always set a deadline:

public sealed class OrderLookup(Orders.OrdersClient client)
{
    public async Task<OrderReply?> TryGetAsync(string id, CancellationToken ct)
    {
        try
        {
            return await client.GetOrderAsync(
                new GetOrderRequest { OrderId = id },
                deadline: DateTime.UtcNow.AddSeconds(5),
                cancellationToken: ct);
        }
        catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
        {
            return null;
        }
    }
}

Deadlines are the single highest-value habit in gRPC. Unlike an HTTP timeout, a deadline propagates: the server sees it in ServerCallContext.Deadline, cancels its own token when it expires, and passes it to downstream gRPC calls. That is what stops one slow database from cascading into a fleet-wide thread pile-up.

Streaming: Where gRPC Really Pulls Ahead

Server streaming replaces polling loops. The method receives an IServerStreamWriter and writes as many messages as it likes:

public override async Task StreamOrderUpdates(
    GetOrderRequest request,
    IServerStreamWriter<OrderReply> responseStream,
    ServerCallContext context)
{
    await foreach (var update in _repository.WatchAsync(request.OrderId, context.CancellationToken))
    {
        await responseStream.WriteAsync(new OrderReply
        {
            OrderId = update.Id,
            Status = update.Status,
            Total = update.Total
        }, context.CancellationToken);
    }
}

Client streaming inverts it — useful for bulk ingestion without a giant single payload:

public override async Task<BulkCreateReply> BulkCreate(
    IAsyncStreamReader<CreateOrderRequest> requestStream,
    ServerCallContext context)
{
    var reply = new BulkCreateReply();

    await foreach (var request in requestStream.ReadAllAsync(context.CancellationToken))
    {
        if (await _repository.CreateAsync(request, context.CancellationToken))
            reply.Created++;
        else
            reply.FailedSkus.Add(request.Sku);
    }

    return reply;
}

Consuming a server stream from the client is just an await foreach:

using var call = client.StreamOrderUpdates(new GetOrderRequest { OrderId = id });

await foreach (var update in call.ResponseStream.ReadAllAsync(ct))
{
    Console.WriteLine($"{update.OrderId} -> {update.Status}");
}

Streaming calls are long-lived, so do not apply a short deadline to them, and always dispose the call (the using above) to release the HTTP/2 stream.

Cross-Cutting Concerns: Interceptors

Interceptors are gRPC's middleware. Use them for logging, correlation IDs, metrics and exception mapping instead of repeating try/catch in every method:

public sealed class ServerLoggingInterceptor(ILogger<ServerLoggingInterceptor> logger)
    : Interceptor
{
    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        var start = Stopwatch.GetTimestamp();
        try
        {
            return await continuation(request, context);
        }
        catch (Exception ex) when (ex is not RpcException)
        {
            logger.LogError(ex, "Unhandled error in {Method}", context.Method);
            throw new RpcException(new Status(StatusCode.Internal, "Internal error."));
        }
        finally
        {
            logger.LogInformation("{Method} took {Elapsed}ms",
                context.Method, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
        }
    }
}

Authentication works exactly as it does in the rest of ASP.NET Core: add JWT bearer auth, call app.UseAuthentication() / app.UseAuthorization(), and decorate services or methods with [Authorize]. On the client, attach the token with a CallCredentials callback so it is refreshed per call rather than captured once.

Best Practices and Common Pitfalls

  • Reuse channels. A GrpcChannel is expensive and thread-safe. Creating one per request destroys the performance benefit and can exhaust sockets. Use the client factory.
  • Never renumber or reuse a field. Delete a field and mark it reserved 4; so nobody recycles the number later.
  • Watch the 100-stream limit. HTTP/2 connections cap concurrent streams (typically 100). Beyond that, calls queue invisibly. Set SocketsHttpHandler.EnableMultipleHttp2Connections = true on high-throughput clients.
  • Prefer google.protobuf.Timestamp and Duration over raw strings or ticks — they are UTC-normalized and understood by every language.
  • Use wrapper types for true nullability. A proto3 int32 defaults to 0, which is indistinguishable from "not sent". Use google.protobuf.Int32Value or the optional keyword when the difference matters.
  • Do not stream tiny messages one at a time. Each message has framing overhead; batch 100–1,000 items per message for bulk transfer.
  • Plan for browsers. Add the Grpc.AspNetCore.Web package and app.UseGrpcWeb() for gRPC-Web, or add Microsoft.AspNetCore.Grpc.JsonTranscoding to expose the same service as a REST/JSON endpoint from HTTP annotations in the proto — one implementation, two protocols.
  • TLS is effectively mandatory. HTTP/2 without TLS requires explicit configuration on both ends; in production, just use HTTPS.
  • Add health checks. Grpc.AspNetCore.HealthChecks implements the standard health protocol that Kubernetes and load balancers already understand.

Advanced gRPC in C#: Load Balancing and Code-First

Two capabilities are worth knowing once you are past the basics. First, client-side load balancing via Grpc.Net.Client.Balancer lets a channel resolve multiple backend addresses (static, DNS, or a custom resolver) and spread calls with a round-robin policy — important because an L4 proxy will otherwise pin every request from a long-lived HTTP/2 connection to a single pod.

Second, if a .proto file feels like ceremony for an all-.NET system, protobuf-net.Grpc offers a code-first model where you declare the contract with C# interfaces and attributes. You keep the wire format and performance, and you give up the polyglot generation story. Choose proto-first when other languages or other teams consume the API; code-first when they never will.

Key Takeaways

Adopting gRPC in C# is less about raw benchmark numbers and more about removing whole categories of integration bugs. You get a versioned contract, generated clients, compile-time safety, deadline propagation and native streaming — all on infrastructure that ASP.NET Core already optimizes hard.

  • Define contracts in .proto files and treat field numbers as permanent.
  • Use AddGrpcClient so channels are reused and retry policies are centralized.
  • Set a deadline on every unary call and honor ServerCallContext.CancellationToken end to end.
  • Reach for streaming instead of polling; batch small messages.
  • Put logging, auth mapping and error translation in interceptors, not in each method.
  • Expose gRPC-Web or JSON transcoding when browsers or curl-based consumers need access.

Start with one chatty internal endpoint, measure the payload size and p99 latency before and after, and let the numbers make the case for the rest of your fleet.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...