gRPC in .NET 10: Service, Client, Streaming and Gotchas

Last updated: October 4, 2026 · Tested with .NET 10 (SDK 10.0.401), Grpc.AspNetCore / Grpc.Net.Client / Grpc.Tools 2.84.0, Google.Protobuf 3.36.2, Windows 11

Short answer: gRPC in .NET uses Grpc.AspNetCore on the server and Grpc.Net.Client on the client. You write the contract in a .proto file, and Grpc.Tools generates a base class to implement and a typed client to call. I built a .NET 10 product service with all four call types (unary, server streaming, client streaming, bidirectional) plus an interceptor, deadlines, retries and a REST endpoint to compare against. Every output below comes from running it. The things worth knowing before you start: the dotnet new grpc template still pins Grpc.AspNetCore 2.64.0. Its Kestrel setting makes the app HTTP/2-only, which breaks every HTTP/1.1 caller. Calls have no deadline unless you set one. And in my measurements, gRPC was not faster than REST for small single calls. What it did give me was half-size payloads, streaming and a typed contract.

The Contract: product.proto

One .proto file, shared by the server and client projects (I keep it in a Protos folder next to both):

syntax = "proto3";

option csharp_namespace = "ProductApi";

package product;

service ProductService {
  rpc GetProduct (ProductRequest) returns (ProductReply);                     // unary
  rpc ListProducts (ProductFilter) returns (stream ProductReply);             // server streaming
  rpc BulkCreate (stream CreateProductRequest) returns (BulkCreateReply);     // client streaming
  rpc WatchPrices (stream PriceSubscription) returns (stream PriceUpdate);    // bidirectional
}

message ProductRequest {
  int32 id = 1;
}

message ProductFilter {
  string category = 1;
  int32 delay_ms = 2;
}

message ProductReply {
  int32 id = 1;
  string name = 2;
  double price = 3;
  bool in_stock = 4;
  string category = 5;
}

message CreateProductRequest {
  string name = 1;
  double price = 2;
}

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

message PriceSubscription {
  int32 product_id = 1;
}

message PriceUpdate {
  int32 product_id = 1;
  double price = 2;
}

The numbers after each field (= 1, = 2) are what goes over the wire, not the names. Never change or reuse a number once clients are deployed. Add new fields with new numbers, and mark removed ones reserved. A field named in_stock becomes the C# property InStock. The proto3 language guide covers the rest of the syntax.

Project Setup

dotnet new grpc -o GrpcDemo.Server gives you a working server. On SDK 10.0.401 the template still references Grpc.AspNetCore 2.64.0, so bump it straight away (2.84.0 is current). Then point both projects at the shared proto. Server .csproj:

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

<ItemGroup>
  <PackageReference Include="Grpc.AspNetCore" Version="2.84.0" />
</ItemGroup>

Client .csproj (a plain console app):

<ItemGroup>
  <PackageReference Include="Google.Protobuf" Version="3.36.2" />
  <PackageReference Include="Grpc.Net.Client" Version="2.84.0" />
  <PackageReference Include="Grpc.Net.ClientFactory" Version="2.84.0" />
  <PackageReference Include="Grpc.Tools" Version="2.84.0" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
  <Protobuf Include="..\Protos\product.proto" GrpcServices="Client" Link="Protos\product.proto" />
</ItemGroup>

GrpcServices="Server" generates ProductService.ProductServiceBase. GrpcServices="Client" generates ProductService.ProductServiceClient. Use "Both" for a project that does both, such as a gateway.

The Service: All Four Call Types

using Grpc.Core;
using ProductApi;

namespace GrpcDemo.Server.Services;

public class ProductCatalog : ProductService.ProductServiceBase
{
    private static readonly ProductReply[] Products =
    [
        new() { Id = 1, Name = "Mechanical Keyboard", Price = 129.99, InStock = true, Category = "Gadgets" },
        new() { Id = 2, Name = "USB-C Dock", Price = 89.50, InStock = false, Category = "Gadgets" },
        new() { Id = 3, Name = "27\" Monitor", Price = 279.00, InStock = true, Category = "Displays" },
        new() { Id = 4, Name = "Webcam", Price = 59.90, InStock = true, Category = "Gadgets" },
    ];

    private readonly ILogger<ProductCatalog> _logger;

    public ProductCatalog(ILogger<ProductCatalog> logger) => _logger = logger;

    // Unary: one request, one response
    public override Task<ProductReply> GetProduct(ProductRequest request, ServerCallContext context)
    {
        var product = Products.FirstOrDefault(p => p.Id == request.Id)
            ?? throw new RpcException(new Status(StatusCode.NotFound, $"Product {request.Id} not found"));

        return Task.FromResult(product);
    }

    // Server streaming: one request, many responses
    public override async Task ListProducts(
        ProductFilter request,
        IServerStreamWriter<ProductReply> responseStream,
        ServerCallContext context)
    {
        foreach (var product in Products.Where(p => p.Category == request.Category))
        {
            await Task.Delay(request.DelayMs, context.CancellationToken);
            await responseStream.WriteAsync(product, context.CancellationToken);
            _logger.LogInformation("Streamed product {Id}", product.Id);
        }
    }

    // Client streaming: many requests, one response
    public override async Task<BulkCreateReply> BulkCreate(
        IAsyncStreamReader<CreateProductRequest> requestStream,
        ServerCallContext context)
    {
        var reply = new BulkCreateReply();
        await foreach (var item in requestStream.ReadAllAsync(context.CancellationToken))
        {
            if (item.Price <= 0)
                reply.Rejected.Add(item.Name);
            else
                reply.Created++;
        }
        return reply;
    }

    // Bidirectional streaming: both sides send whenever they want
    public override async Task WatchPrices(
        IAsyncStreamReader<PriceSubscription> requestStream,
        IServerStreamWriter<PriceUpdate> responseStream,
        ServerCallContext context)
    {
        await foreach (var sub in requestStream.ReadAllAsync(context.CancellationToken))
        {
            var product = Products.First(p => p.Id == sub.ProductId);
            for (var i = 0; i < 2; i++)
            {
                await responseStream.WriteAsync(new PriceUpdate
                {
                    ProductId = product.Id,
                    Price = Math.Round(product.Price * (1 - 0.05 * i), 2)
                }, context.CancellationToken);
            }
        }
    }
}

Pass context.CancellationToken to everything async in a streaming method. When the client disconnects or its deadline passes, that token fires and the server stops working on a call nobody is waiting for. You can see that in the deadline test below.

Program.cs and an Interceptor

using GrpcDemo.Server;
using GrpcDemo.Server.Services;

var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();

app.MapGrpcService<ProductCatalog>();

app.Run();

Interceptors are gRPC’s middleware. This one logs the duration and status of every unary call:

using System.Diagnostics;
using Grpc.Core;
using Grpc.Core.Interceptors;

namespace GrpcDemo.Server;

public class ServerLoggingInterceptor : Interceptor
{
    private readonly ILogger<ServerLoggingInterceptor> _logger;

    public ServerLoggingInterceptor(ILogger<ServerLoggingInterceptor> logger) => _logger = logger;

    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        var sw = Stopwatch.StartNew();
        try
        {
            return await continuation(request, context);
        }
        catch (RpcException ex)
        {
            _logger.LogWarning("{Method} -> {Status} in {Ms} ms", context.Method, ex.StatusCode, sw.ElapsedMilliseconds);
            throw;
        }
        finally
        {
            _logger.LogInformation("{Method} finished in {Ms} ms (deadline {Deadline:O})",
                context.Method, sw.ElapsedMilliseconds, context.Deadline);
        }
    }
}

The interceptor taught me something. Every call my test client made without a deadline logged deadline 9999-12-31T23:59:59.9999999, which is DateTime.MaxValue. A gRPC call has no timeout by default. If the server hangs, the client waits forever.

info: GrpcDemo.Server.ServerLoggingInterceptor[0]
      /product.ProductService/GetProduct finished in 2 ms (deadline 9999-12-31T23:59:59.9999999)
warn: GrpcDemo.Server.ServerLoggingInterceptor[0]
      /product.ProductService/GetProduct -> NotFound in 0 ms

The Client

using Grpc.Core;
using Grpc.Net.Client;
using ProductApi;

using var channel = GrpcChannel.ForAddress("https://localhost:7184");
var client = new ProductService.ProductServiceClient(channel);

// Unary
var product = await client.GetProductAsync(new ProductRequest { Id = 1 });
Console.WriteLine($"Unary:   {product.Name} ${product.Price} (in stock: {product.InStock})");

// Server streaming
using var call = client.ListProducts(new ProductFilter { Category = "Gadgets" });
await foreach (var item in call.ResponseStream.ReadAllAsync())
    Console.WriteLine($"Stream:  {item.Id} {item.Name}");

// Client streaming
using var bulk = client.BulkCreate();
foreach (var (name, price) in new[] { ("Mouse", 25.0), ("Broken item", 0.0), ("Headset", 79.0) })
    await bulk.RequestStream.WriteAsync(new CreateProductRequest { Name = name, Price = price });
await bulk.RequestStream.CompleteAsync();
var result = await bulk;
Console.WriteLine($"Bulk:    created {result.Created}, rejected [{string.Join(", ", result.Rejected)}]");

// Bidirectional streaming
using var watch = client.WatchPrices();
var reader = Task.Run(async () =>
{
    await foreach (var u in watch.ResponseStream.ReadAllAsync())
        Console.WriteLine($"Bidi:    product {u.ProductId} -> {u.Price}");
});
await watch.RequestStream.WriteAsync(new PriceSubscription { ProductId = 1 });
await watch.RequestStream.WriteAsync(new PriceSubscription { ProductId = 3 });
await watch.RequestStream.CompleteAsync();
await reader;

Output:

Unary:   Mechanical Keyboard $129.99 (in stock: True)
Stream:  1 Mechanical Keyboard
Stream:  2 USB-C Dock
Stream:  4 Webcam
Bulk:    created 2, rejected [Broken item]
Bidi:    product 1 -> 129.99
Bidi:    product 1 -> 123.49
Bidi:    product 3 -> 279
Bidi:    product 3 -> 265.05

For streaming calls, using matters: disposing the call releases the HTTP/2 stream. With client and bidirectional streaming, always call RequestStream.CompleteAsync(). Otherwise the server’s ReadAllAsync never ends, and the client waits on a response that will never come.

Errors: Status Codes, and What Leaks in Development

Throw RpcException with a real status code for expected failures. Anything else becomes Unknown. I called GetProduct with a missing id, then with an id that made the handler throw InvalidOperationException("Database connection string 'Server=db01;Password=...' is invalid"):

// ASPNETCORE_ENVIRONMENT=Development (EnableDetailedErrors = true)
Id 99:  StatusCode=NotFound, Detail="Product 99 not found"
Id 500: StatusCode=Unknown, Detail="Exception was thrown by handler. InvalidOperationException: Database connection string 'Server=db01;Password=...' is invalid"

// ASPNETCORE_ENVIRONMENT=Production
Id 99:  StatusCode=NotFound, Detail="Product 99 not found"
Id 500: StatusCode=Unknown, Detail="Exception was thrown by handler."

EnableDetailedErrors sends the exception message to the caller, and messages often contain things you don’t want in a client log. Tie it to the environment as in Program.cs above, and never turn it on in production.

Deadlines

The stream sent one product every 400 ms, and I gave the call a 1-second deadline:

var sw = Stopwatch.StartNew();
try
{
    using var call = client.ListProducts(
        new ProductFilter { Category = "Gadgets", DelayMs = 400 },
        deadline: DateTime.UtcNow.AddMilliseconds(1000));
    await foreach (var item in call.ResponseStream.ReadAllAsync())
        Console.WriteLine($"{sw.ElapsedMilliseconds,5} ms  got {item.Name}");
}
catch (RpcException ex)
{
    Console.WriteLine($"{sw.ElapsedMilliseconds,5} ms  {ex.StatusCode}: {ex.Status.Detail}");
}
  558 ms  got Mechanical Keyboard
  953 ms  got USB-C Dock
 1043 ms  DeadlineExceeded:

The client got DeadlineExceeded (with an empty detail) and the server logged Service method 'ListProducts' canceled., because its CancellationToken fired. One trap: the deadline must be UTC. Passing DateTime.Now.AddSeconds(5) doesn’t time out later. It throws before the call is sent:

InvalidOperationException: Deadline must have a kind DateTimeKind.Utc or be equal to DateTime.MaxValue or DateTime.MinValue.

Set a deadline on every unary call. For long-lived streams, use a CancellationToken you control instead of a short deadline. Microsoft’s deadlines and cancellation guide also covers propagating deadlines to downstream gRPC calls.

Client Factory and Retries

Create channels once and reuse them. In an ASP.NET Core app or worker, register the client with Grpc.Net.ClientFactory and inject it. The factory can also add a retry policy:

using Grpc.Net.Client.Configuration;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddGrpcClient<ProductService.ProductServiceClient>(o => o.Address = new Uri("https://localhost:7184"))
    .ConfigureChannel(o => o.ServiceConfig = new ServiceConfig
    {
        MethodConfigs =
        {
            new MethodConfig
            {
                Names = { MethodName.Default },
                RetryPolicy = new RetryPolicy
                {
                    MaxAttempts = 4,
                    InitialBackoff = TimeSpan.FromMilliseconds(200),
                    MaxBackoff = TimeSpan.FromSeconds(2),
                    BackoffMultiplier = 2,
                    RetryableStatusCodes = { StatusCode.Unavailable }
                }
            }
        }
    });

To test it, I made the server return Unavailable twice and succeed on the third attempt:

// plain channel, no retry policy
Unavailable: Warming up

// AddGrpcClient + RetryPolicy
699 ms  OK: Recovered on attempt 3

Only retry status codes that mean “try again” (Unavailable, sometimes ResourceExhausted). Never retry InvalidArgument or NotFound. The retry policy also doesn’t help when nothing is listening: with the server stopped, each attempt took about 2 seconds to fail on Windows (Unavailable: Error connecting to subchannel.). Microsoft’s retry docs describe hedging as an alternative.

Gotcha: HTTP/2-Only Kestrel vs HTTP/1.1 Callers

The template’s appsettings.json contains:

"Kestrel": {
  "EndpointDefaults": {
    "Protocols": "Http2"
  }
}

That’s fine for pure gRPC. But the moment you add a REST endpoint, a health check or anything else called over HTTP/1.1, it breaks. I added app.MapGet("/api/products/{id:int}", ...) and called it:

curl http://localhost:5291/api/products/1          (HTTP/1.1, no TLS)
HTTP/1.1 400 Bad Request
An HTTP/1.x request was sent to an HTTP/2 only endpoint.

curl https://localhost:7184/api/products/1         (curl build without HTTP/2)
schannel: ... No common application protocol exists between the client and the server.
Application protocol negotiation failed.

HttpClient (default HTTP/1.1) over https
HttpRequestException: An error occurred while sending the request.
 ---> IOException: Unable to read data from the transport connection:
      An established connection was aborted by the software in your host machine.

Load balancer health probes usually speak HTTP/1.1, which is how this ends up as an outage. The fix looks obvious: switch to Http1AndHttp2. But that breaks gRPC over plain http://, because without TLS there is no protocol negotiation and Kestrel treats the connection as HTTP/1.1:

// Protocols = Http1AndHttp2, client uses http://localhost:5291
StatusCode=Internal, Detail="Error starting gRPC call. HttpRequestException: The HTTP/2 server
closed the connection. HTTP/2 error code 'HTTP_1_1_REQUIRED' (0xd). (HttpProtocolError)"
Kestrel endpointgRPCREST / health checks over HTTP/1.1
https, Http1AndHttp2✅ (HTTP/2 via ALPN)✅
https, Http2✅❌ TLS negotiation fails
http, Http2✅❌ 400
http, Http1AndHttp2❌ HTTP_1_1_REQUIRED✅

Use TLS with Http1AndHttp2 when one app serves both. If TLS ends at a proxy and the app gets plain HTTP, use two Kestrel endpoints: an Http2 port for gRPC and an Http1 port for REST and health checks.

Gotcha: The 4 MB Message Limit

Both sides cap incoming messages at 4 MB by default. I streamed one CreateProductRequest with a 5 MB name:

ResourceExhausted: Received message exceeds the maximum configured message size.

Raise MaxReceiveMessageSize only if you must. For big data, stream it in chunks (client streaming is exactly that) instead of one huge message.

gRPC vs REST: What I Measured

Same machine (Intel Core Ultra 5 125U laptop, Release builds, server logging off), same product object. gRPC GetProduct against a minimal API returning the same object as JSON, over https. These are the last round of three, two runs of 10,000 calls each:

gRPCREST, HTTP/1.1REST, HTTP/2
Sequential, µs per call158–171102–120129–151
50 in parallel, µs per call14–1818–199
Payload43 bytes (protobuf)88 bytes (JSON)

For a small message on localhost, gRPC was slower one call at a time and no better in parallel. The protobuf payload was half the size of the JSON (43 vs 88 bytes). That matters over a real network and at volume, but it doesn’t show up on loopback. Treat these as numbers for this one shape of call, not a general benchmark. Larger messages, real network latency and streaming will change the picture.

What gRPC really gave me was not raw speed. It was:

  • A typed contract that both sides compile against, so a renamed field breaks the build, not production.
  • Four call types, including true bidirectional streaming over one connection.
  • Deadlines, cancellation and retries as part of the protocol.
  • Smaller payloads.

Use gRPC for service-to-service calls you control on both ends, for example in a .NET microservices setup. Keep REST for public and browser-facing APIs. Browsers can’t call gRPC directly and need gRPC-Web. REST also keeps the usual tooling (curl, caching, URL versioning, rate limiting per route).

Checklist

  • Bump the template’s Grpc.AspNetCore 2.64.0 to the current version.
  • One shared .proto. Never reuse field numbers.
  • A deadline on every unary call, in UTC.
  • context.CancellationToken passed through every streaming method.
  • RpcException with a real status code for expected errors. EnableDetailedErrors only in Development.
  • AddGrpcClient (one channel, reused) with a retry policy for Unavailable.
  • Check Kestrel Protocols before adding REST or health endpoints.
  • Stream large data. Don’t raise the 4 MB limit by default.
  • When deploying to AKS or another load balancer, make sure HTTP/2 is kept end to end.

FAQ

Is gRPC faster than REST in .NET? Not automatically. In my test, small unary calls were slower over gRPC (158–171 µs vs 102–120 µs for REST over HTTP/1.1), while the payload was half the size. Measure your own message shapes.

Why do I get HTTP_1_1_REQUIRED with gRPC? The client is calling an http:// endpoint configured as Http1AndHttp2. Without TLS, Kestrel can’t negotiate HTTP/2. Use https, or a separate Http2-only endpoint for gRPC.

Why does my REST endpoint return “An HTTP/1.x request was sent to an HTTP/2 only endpoint”? The gRPC template sets Kestrel:EndpointDefaults:Protocols to Http2. Change it to Http1AndHttp2 (with TLS), or add a second endpoint.

Do gRPC calls in .NET have a default timeout? No. The deadline is DateTime.MaxValue unless you pass one, and it must be a UTC DateTime.

Can a browser call a gRPC service? Not directly. Use gRPC-Web (Grpc.AspNetCore.Web), or expose a REST or JSON endpoint for browser clients.

Conclusion

gRPC in .NET is pleasant once it runs: a contract, generated code, and four call types that all worked first time. The problems I hit were configuration and defaults, not the programming model. An outdated template package, an HTTP/2-only setting that locks out HTTP/1.1 callers, no default deadline, UTC-only deadlines, and error details that leak in Development. Choose it for the contract and streaming, not for a speed-up you haven’t measured.

References: Microsoft: gRPC on .NET · Deadlines and cancellation · Transient fault handling and retries · Proto3 language guide

This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *