gRPC in .NET is a contract-first remote procedure call framework that runs on ASP.NET Core and HTTP/2 and uses Protocol Buffers to generate strongly typed servers and clients. It suits low-latency service-to-service calls and streaming workloads that REST handles awkwardly. This guide is for developers who already know ASP.NET Core. It covers contract design, all four call types, the client factory, interceptors, deadlines, status codes, gRPC-Web, JSON transcoding and the load-balancing traps that catch most teams in production.
What Is gRPC in .NET?#
gRPC combines three ideas: a service contract written in Protocol Buffers, a compact binary serialization format, and HTTP/2 as the transport. You describe services and messages once in a .proto file, and tooling generates server base classes and client proxies for every major language. A C# service can therefore be called from Go, Java or Python without hand-written glue.
The .NET implementation, grpc-dotnet, is fully managed. Services plug into Kestrel and endpoint routing, so they get dependency injection, logging, authentication and authorization like any other endpoint, and the client builds on HttpClient. The older Grpc.Core package, a wrapper around the native C-core library, has been in maintenance mode since May 2021 and last shipped in January 2023. Do not use it for new work.
| Package | Purpose |
|---|---|
Grpc.AspNetCore | Server metapackage, including Google.Protobuf, Grpc.Tools and client factory integration |
Grpc.Net.Client and Grpc.Net.ClientFactory | The managed client and its IHttpClientFactory integration |
Grpc.Tools | Build-time code generation from .proto files |
Grpc.AspNetCore.Web and Grpc.Net.Client.Web | gRPC-Web middleware and client handler |
Microsoft.AspNetCore.Grpc.JsonTranscoding | REST endpoints generated from annotated gRPC methods |
Grpc.StatusProto | Rich error model helpers |
The grpc-dotnet packages are at version 2.84.0, released in September 2026. The client targets .NET 8, 9 and 10 as well as .NET Standard 2.0/2.1 and .NET Framework 4.6.2, so one generated client serves new services and legacy apps alike.
How gRPC Works on HTTP/2#
Each gRPC call maps onto one HTTP/2 stream. The client sends a POST to a path built from the package, service and method names, such as /inventory.v1.InventoryService/GetStock, with the content type application/grpc. Every message is framed with a compression flag and a four-byte length prefix, so a stream is simply a sequence of framed messages.
Two details explain much of gRPC's operational behavior. First, the call's outcome travels in HTTP trailers (grpc-status and grpc-message), and the HTTP status is normally 200 even when the call fails. Gateways, dashboards and HTTP retry handlers that only read status codes therefore see success. Second, the client's deadline travels in a grpc-timeout header, so the server can abandon work the caller no longer wants.
A client GrpcChannel owns long-lived HTTP/2 connections and multiplexes concurrent calls over them. Channels are expensive to create and thread-safe, while generated clients are cheap wrappers. On the server, MapGrpcService<T>() registers the methods, and each call gets a new, scoped service instance.
| Method type | .proto syntax | Generated server method | Typical use |
|---|---|---|---|
| Unary | rpc Get (Req) returns (Res) | Task<Res> Get(Req, ServerCallContext) | Queries and commands |
| Server streaming | rpc Watch (Req) returns (stream Res) | Task Watch(Req, IServerStreamWriter<Res>, ServerCallContext) | Change feeds, progress |
| Client streaming | rpc Upload (stream Req) returns (Res) | Task<Res> Upload(IAsyncStreamReader<Req>, ServerCallContext) | Batched ingestion |
| Bidirectional | rpc Chat (stream Req) returns (stream Res) | Task Chat(IAsyncStreamReader<Req>, IServerStreamWriter<Res>, ServerCallContext) | Interactive sessions |
Getting Started with a gRPC Service in .NET 10#
The inventory service below runs through the rest of this guide. Keep the contract in a shared location so the server and every client compile from the same file.
syntax = "proto3";
package inventory.v1;
import "google/protobuf/timestamp.proto";
option csharp_namespace = "Contoso.Inventory.V1";
service InventoryService {
rpc GetStock (GetStockRequest) returns (StockLevel);
rpc AdjustStock (StockAdjustment) returns (StockLevel);
rpc WatchStock (WatchStockRequest) returns (stream StockLevel);
rpc ImportAdjustments (stream StockAdjustment) returns (ImportSummary);
rpc Reserve (stream ReserveRequest) returns (stream ReserveResult);
}
message GetStockRequest {
string sku = 1;
}
message StockLevel {
string sku = 1;
int32 on_hand = 2;
int32 reserved = 3;
google.protobuf.Timestamp updated_at = 4;
}
message StockAdjustment {
string sku = 1;
int32 delta = 2;
string reason = 3;
}
message WatchStockRequest {
repeated string skus = 1;
}
message ImportSummary {
int32 applied = 1;
int32 rejected = 2;
}
message ReserveRequest {
string sku = 1;
int32 quantity = 2;
}
message ReserveResult {
string sku = 1;
bool accepted = 2;
}Grpc.Tools runs the Protobuf compiler on every build and writes the generated C# to obj/, so nothing generated is checked in. The GrpcServices attribute selects Both (the default), Server, Client or None.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- Server project: generate messages and the service base class only. -->
<Protobuf Include="..\Contracts\inventory.proto" GrpcServices="Server" />
<PackageReference Include="Grpc.AspNetCore" Version="2.84.0" />
</ItemGroup>
<!-- A client project uses GrpcServices="Client" plus these references:
<PackageReference Include="Google.Protobuf" Version="3.36.2" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.84.0" />
<PackageReference Include="Grpc.Tools" Version="2.84.0" PrivateAssets="All" />
-->
</Project>Generated code follows C# conventions. on_hand becomes OnHand, repeated fields become read-only RepeatedField<T> properties, and the service yields a static InventoryService class with nested InventoryServiceBase and InventoryServiceClient types. Override the methods you implement; the rest return UNIMPLEMENTED.
// Services/InventoryGrpcService.cs
using Contoso.Inventory.V1;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
namespace Contoso.Inventory.Services;
public sealed partial class InventoryGrpcService(IStockStore store)
: InventoryService.InventoryServiceBase
{
public override async Task<StockLevel> GetStock(
GetStockRequest request, ServerCallContext context)
{
if (string.IsNullOrWhiteSpace(request.Sku))
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "sku is required."));
}
var item = await store.FindAsync(request.Sku, context.CancellationToken)
?? throw new RpcException(
new Status(StatusCode.NotFound, $"SKU '{request.Sku}' was not found."));
return new StockLevel
{
Sku = item.Sku,
OnHand = item.OnHand,
Reserved = item.Reserved,
UpdatedAt = Timestamp.FromDateTimeOffset(item.UpdatedAt)
};
}
}
// Program.cs
using Contoso.Inventory.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaxReceiveMessageSize = 2 * 1024 * 1024; // default is 4 MB
});
builder.Services.AddSingleton<IStockStore, SqlStockStore>();
var app = builder.Build();
app.MapGrpcService<InventoryGrpcService>();
app.MapGet("/", () => "gRPC endpoint. Use a gRPC client to call it.");
app.Run();The service template configures Kestrel for HTTP/2 only, which is what native gRPC needs. A console client needs only a channel and the generated client:
using Contoso.Inventory.V1;
using Grpc.Net.Client;
using var channel = GrpcChannel.ForAddress("https://localhost:7042");
var client = new InventoryService.InventoryServiceClient(channel);
var stock = await client.GetStockAsync(new GetStockRequest { Sku = "KB-104" });
Console.WriteLine($"{stock.Sku}: {stock.OnHand} on hand, {stock.Reserved} reserved");Unary methods also get blocking twins, such as GetStock. Avoid them, because blocking ties up thread-pool threads under load.
Designing Protocol Buffers Contracts That Evolve#
Field numbers, not names, identify data on the wire, so never change or reuse one, and mark deleted fields as reserved. Adding fields and methods is non-breaking as long as the server copes when older clients leave new fields unset. Renaming a package, service or method breaks callers, who then get UNIMPLEMENTED. Put a version in the package name (inventory.v1) so you can host v2 side by side when a breaking change is unavoidable.
- Use
google.protobuf.TimestampandDurationfor time. They convert toDateTimeOffsetandTimeSpan, and timestamps are always UTC. - Scalars cannot be null. Use wrapper types such as
google.protobuf.Int32Value(which generatesint?) or proto3optionalwhen "unset" must differ from zero. - Protobuf has no decimal type. Represent money as
unitsplusnanosfields or as integer minor units. - Start every enum with an
..._UNSPECIFIED = 0value, because zero is what missing fields produce.
Code-first contracts through the community protobuf-net.Grpc library are an option in .NET-only systems, but Microsoft does not support that library and it fits polyglot estates poorly.
Streaming Calls: Server, Client and Bidirectional#
Server streams suit change feeds, and client streams let a caller push many small messages without per-request overhead. The server writes with IServerStreamWriter<T> and reads with IAsyncStreamReader<T>.
// Server: add to the partial InventoryGrpcService class
public override async Task WatchStock(
WatchStockRequest request,
IServerStreamWriter<StockLevel> responseStream,
ServerCallContext context)
{
// Fires when the client cancels, the deadline passes or the connection drops.
var ct = context.CancellationToken;
await foreach (var level in store.WatchAsync(request.Skus, ct))
{
await responseStream.WriteAsync(level, ct);
}
}
public override async Task<ImportSummary> ImportAdjustments(
IAsyncStreamReader<StockAdjustment> requestStream, ServerCallContext context)
{
var summary = new ImportSummary();
await foreach (var item in requestStream.ReadAllAsync(context.CancellationToken))
{
var applied = await store.TryAdjustAsync(item.Sku, item.Delta, context.CancellationToken);
if (applied) summary.Applied++;
else summary.Rejected++;
}
return summary;
}
// Client: consume a server stream, then upload a client stream
using var watch = client.WatchStock(
new WatchStockRequest { Skus = { "KB-104", "MS-220" } },
cancellationToken: stoppingToken);
await foreach (var level in watch.ResponseStream.ReadAllAsync(stoppingToken))
{
logger.LogInformation("{Sku} now has {OnHand} on hand", level.Sku, level.OnHand);
}
using var upload = client.ImportAdjustments(cancellationToken: stoppingToken);
foreach (var adjustment in pendingAdjustments)
{
await upload.RequestStream.WriteAsync(adjustment, stoppingToken);
}
await upload.RequestStream.CompleteAsync();
var summary = await upload;A stream reader and a stream writer each allow only one thread at a time, so funnel multiple producers through a bounded Channel<T> with a single writing loop. Reading and writing concurrently is fine for a bidirectional call such as Reserve. ServerCallContext and both streams become invalid when the method returns, so await background work first. Clients should dispose streaming calls and call CompleteAsync() on request streams so both sides finish cleanly.
Always await WriteAsync. HTTP/2 flow control pushes back when a consumer stops reading, and awaiting is how that backpressure reaches your producer. Send large payloads as chunks on a stream rather than as one giant message.
Grpc.Net.Client and the gRPC Client Factory#
GrpcChannel.ForAddress suits tools and tests, but applications should use Grpc.Net.ClientFactory. It builds on IHttpClientFactory, registers generated clients as transient services and exposes the extension points you need in production:
using Contoso.Inventory.V1;
using Grpc.Core;
using Grpc.Net.Client.Configuration;
builder.Services.AddScoped<ITokenProvider, WorkloadTokenProvider>();
builder.Services.AddSingleton<ClientMetricsInterceptor>();
builder.Services
.AddGrpcClient<InventoryService.InventoryServiceClient>(o =>
{
o.Address = new Uri(builder.Configuration["Services:Inventory"]!);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true, // exceed the 100-stream limit per connection
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30)
})
.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 }
}
}
}
})
.AddInterceptor<ClientMetricsInterceptor>()
.AddCallCredentials(async (context, metadata, services) =>
{
var tokens = services.GetRequiredService<ITokenProvider>();
var token = await tokens.GetTokenAsync(context.CancellationToken);
metadata.Add("Authorization", $"Bearer {token}");
})
.EnableCallContextPropagation(o => o.SuppressContextNotFoundErrors = true);Servers typically allow 100 concurrent streams per connection, and extra calls queue on the client. EnableMultipleHttp2Connections opens more connections instead. Keep-alive pings detect connections that a NAT device or cloud load balancer silently dropped.
Retries configured in ServiceConfig understand gRPC status codes and replay buffered requests. A channel caps attempts at 5 by default and buffers up to 16 MB per channel and 1 MB per call. A call is retried only until it is committed, so once response headers arrive nothing is replayed. HedgingPolicy is the alternative for idempotent, latency-sensitive reads. Prefer these over generic HTTP handlers such as the ones in Polly-based resilience, because they see HTTP 200 for most failed calls.
AddCallCredentials requires TLS. On a plaintext address the factory throws unless you set UnsafeUseInsecureChannelCallCredentials, which is only acceptable when a service mesh encrypts traffic.
Interceptors for Cross-Cutting Concerns#
Interceptors derive from Interceptor in Grpc.Core.Interceptors and wrap calls at the message level. Clients override methods such as AsyncUnaryCall and AsyncServerStreamingCall. Servers override UnaryServerHandler, ServerStreamingServerHandler, ClientStreamingServerHandler and DuplexStreamingServerHandler. A common server interceptor maps domain exceptions to status codes:
// Interceptors/ExceptionMappingInterceptor.cs
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using Grpc.Core;
using Grpc.Core.Interceptors;
namespace Contoso.Inventory.Interceptors;
public sealed class ExceptionMappingInterceptor(ILogger<ExceptionMappingInterceptor> logger)
: Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var started = Stopwatch.GetTimestamp();
try
{
return await continuation(request, context);
}
catch (ValidationException ex)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, ex.Message));
}
catch (KeyNotFoundException ex)
{
throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
}
finally
{
logger.LogDebug("{Method} took {ElapsedMs:F1} ms", context.Method,
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
}
}
// Program.cs: global registration (per-service registration uses AddServiceOptions<T>)
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<ExceptionMappingInterceptor>();
});Server interceptors are created from DI per request by default, and global interceptors run before per-service ones. Client interceptors added with AddInterceptor are resolved from DI and shared between clients unless you pass InterceptorScope.Client. Middleware runs first and sees only HTTP frames, while interceptors see method names, typed messages and ServerCallContext. Use middleware for transport concerns such as authentication, and interceptors for validation, status mapping and per-method metrics.
Deadlines and Cancellation#
gRPC has no default deadline, so a call to a stuck dependency can wait forever while holding resources. Set one on every outbound call. The deadline is an absolute UTC time that both sides track independently.
public async Task<int?> TryGetOnHandAsync(string sku, CancellationToken cancellationToken)
{
try
{
var level = await client.GetStockAsync(
new GetStockRequest { Sku = sku },
deadline: DateTime.UtcNow.AddSeconds(2),
cancellationToken: cancellationToken);
return level.OnHand;
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
{
// The server may still have finished the work, so keep operations idempotent.
logger.LogWarning("Stock lookup for {Sku} exceeded its deadline", sku);
return null;
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
return null;
}
}When the deadline passes, the client fails with DeadlineExceeded and the server's context.CancellationToken fires. That only frees resources if you pass the token to every database call and stream operation. A caller that cancels its own token gets Cancelled, or an OperationCanceledException if the channel sets ThrowOperationCanceledOnCancellation.
In call chains, a downstream service should inherit what remains of the caller's budget. EnableCallContextPropagation, shown earlier, flows the deadline and cancellation token into outbound calls. If a child call sets its own deadline, the smaller one wins, and configured retries share the same deadline.
Error Handling with gRPC Status Codes#
Services report failures by throwing RpcException with a StatusCode and a message. Other exceptions become UNKNOWN with a generic message so internals do not leak. EnableDetailedErrors reveals them and belongs in development only. Choose codes deliberately, because clients, retry policies and dashboards branch on them.
| Status code | Use it when | HTTP equivalent | Retry? |
|---|---|---|---|
InvalidArgument | The request is malformed | 400 | No |
FailedPrecondition | The system is not in the required state | 400 | After state changes |
NotFound | The entity does not exist | 404 | No |
Aborted | A concurrency conflict occurred | 409 | Yes, at a higher level |
Unauthenticated / PermissionDenied | Credentials are missing or insufficient | 401 / 403 | No |
ResourceExhausted | A quota or rate limit was hit | 429 | Yes, with backoff |
DeadlineExceeded | The deadline expired | 504 | Only if idempotent |
Unavailable | A transient failure, such as a restarting instance | 503 | Yes |
For structured errors, Grpc.StatusProto sends a google.rpc.Status with typed details such as BadRequest, ErrorInfo or QuotaFailure:
using Google.Protobuf.WellKnownTypes;
using Google.Rpc;
using Grpc.Core;
// Server: report exactly which fields are wrong
var status = new Google.Rpc.Status
{
Code = (int)Code.InvalidArgument,
Message = "The reservation request is invalid.",
Details =
{
Any.Pack(new BadRequest
{
FieldViolations =
{
new BadRequest.Types.FieldViolation
{
Field = "quantity",
Description = "Quantity must be between 1 and 100."
}
}
})
}
};
throw status.ToRpcException();
// Client: read the structured details back
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
{
var badRequest = ex.GetRpcStatus()?.GetDetail<BadRequest>();
if (badRequest is not null)
{
foreach (var violation in badRequest.FieldViolations)
{
logger.LogWarning("{Field}: {Description}", violation.Field, violation.Description);
}
}
}Error details travel in headers, which many servers and proxies cap at about 8 KB, so keep them compact. Custom trailers are available through GetTrailers() or RpcException.Trailers.
gRPC-Web and JSON Transcoding for Browsers and REST Clients#
Browsers cannot make native gRPC calls, because their APIs do not expose HTTP/2 frames or trailers. ASP.NET Core offers two in-process options.
gRPC-Web works over HTTP/1.1 and HTTP/2 and keeps Protobuf payloads, but browser clients can only call unary and server streaming methods. Clients use the JavaScript grpc-web library or the .NET client with GrpcWebHandler:
// Server
builder.Services.AddGrpc();
builder.Services.AddCors(o => o.AddPolicy("grpc-web", policy => policy
.WithOrigins("https://shop.contoso.com")
.AllowAnyMethod()
.AllowAnyHeader()
.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding",
"Grpc-Accept-Encoding", "Grpc-Status-Details-Bin")));
var app = builder.Build();
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); // after routing
app.UseCors();
app.MapGrpcService<InventoryGrpcService>().RequireCors("grpc-web");
// Blazor WebAssembly client
builder.Services
.AddGrpcClient<InventoryService.InventoryServiceClient>(o =>
o.Address = new Uri(builder.HostEnvironment.BaseAddress))
.ConfigurePrimaryHttpMessageHandler(() => new GrpcWebHandler(new HttpClientHandler()));Without the exposed headers, the browser hides the gRPC status from your code. Server streaming in browsers also needs GrpcWebMode.GrpcWebText. Teams that already run Envoy can use its gRPC-Web filter instead.
JSON transcoding (.NET 7 and later) exposes annotated methods as plain REST endpoints, so callers need no gRPC tooling. Add Microsoft.AspNetCore.Grpc.JsonTranscoding, call AddGrpc().AddJsonTranscoding(), set <IncludeHttpRuleProtos>true</IncludeHttpRuleProtos> in the project file and annotate methods:
syntax = "proto3";
package inventory.v1;
import "google/api/annotations.proto";
service InventoryService {
rpc GetStock (GetStockRequest) returns (StockLevel) {
option (google.api.http) = {
get: "/v1/stock/{sku}"
};
}
rpc AdjustStock (StockAdjustment) returns (StockLevel) {
option (google.api.http) = {
post: "/v1/stock/{sku}/adjustments"
body: "*"
};
}
}GET /v1/stock/KB-104 now runs the same GetStock code as a gRPC call. Route parameters bind to fields, body: "*" maps the remaining fields from JSON, and server streaming is written as line-delimited JSON. Client and bidirectional streaming are not supported. Enable Http1AndHttp2 on Kestrel, and note that negotiating both protocols on one port requires TLS. JSON customization is limited, so build a Minimal API when you need full control over the payload shape. The experimental Microsoft.AspNetCore.Grpc.Swagger package, which produced OpenAPI documents for transcoded APIs, was deprecated in 2026 without a direct replacement.
Load Balancing Long-Lived HTTP/2 Connections#
A layer 4 load balancer balances connections, not calls. Because a gRPC client multiplexes everything over one long-lived HTTP/2 connection, all of its traffic lands on one server, and a default Kubernetes service leaves newly scaled pods idle. There are two fixes.
The first is an L7 proxy that balances individual calls, such as Envoy, a service mesh like Linkerd or Istio, or a YARP gateway. Clients stay simple, at the cost of an extra hop. The second is client-side load balancing, built into Grpc.Net.Client since version 2.45.0. Combined with a Kubernetes headless service, which returns one DNS record per pod, it removes the proxy entirely:
using Contoso.Inventory.V1;
using Grpc.Core;
using Grpc.Net.Client.Balancer;
using Grpc.Net.Client.Configuration;
// Re-resolve DNS every 30 seconds so new pods receive traffic quickly.
builder.Services.AddSingleton<ResolverFactory>(
new DnsResolverFactory(refreshInterval: TimeSpan.FromSeconds(30)));
builder.Services
.AddGrpcClient<InventoryService.InventoryServiceClient>(o =>
{
// Headless service: DNS returns the address of every ready pod.
o.Address = new Uri("dns:///inventory-headless.shop.svc.cluster.local:8080");
})
.ConfigureChannel(o =>
{
// The scheme now selects the resolver, so transport security is set explicitly.
o.Credentials = ChannelCredentials.Insecure; // or ChannelCredentials.SecureSsl
o.ServiceConfig = new ServiceConfig { LoadBalancingConfigs = { new RoundRobinConfig() } };
});Without a service config, the channel uses pick_first and sends everything to one address, while round_robin spreads calls across all connections. The resolver refreshes when connections drop, and the refresh interval picks up new pods sooner. Balancing state lives in the channel, so reuse it; the client factory does that for you. See Running .NET on Kubernetes for the cluster networking side.
Best Practices#
- Reuse channels. Prefer the client factory, because a channel per call discards connections and balancing state.
- Set and propagate deadlines on every call, and pass
context.CancellationTokento all I/O. - Map exceptions to status codes in one interceptor, and reserve
Unavailablefor conditions that are safe to retry. - Version packages, not methods. Use
v1in the package, reserve deleted field numbers and share.protofiles from one source. - Balance calls, not connections, and enable keep-alive pings for long-lived streams.
- Keep messages small. Chunk large data and review the 4 MB receive limit instead of simply raising it.
- Restrict reflection to development, and expose
MapGrpcHealthChecksService()for orchestrators.
Common Pitfalls#
- Blocking on calls with
.Result,.Wait()or generated blocking methods, which starves the thread pool. - Trusting HTTP status codes in metrics and retry logic instead of reading
grpc-status. - Using a stream writer after the handler returns, or writing to it from two threads at once.
- Hitting the 100-stream limit on busy clients without enabling multiple HTTP/2 connections.
- Serving gRPC-Web or transcoding from an HTTP/2-only endpoint, which HTTP/1.1 callers cannot reach.
- Leaving
EnableDetailedErrorson in production.
gRPC vs REST: Choosing the Right Protocol#
Many systems use both, with REST at the public edge and gRPC between services. JSON transcoding even lets one implementation serve both.
| Concern | gRPC | REST with JSON |
|---|---|---|
| Contract | Required .proto file | Optional OpenAPI document |
| Payload | Binary Protobuf, small and fast | Text JSON, human readable |
| Transport | HTTP/2 (gRPC-Web for HTTP/1.1) | Any HTTP version |
| Streaming | Server, client and bidirectional | Response streaming or SSE |
| Browser support | Needs gRPC-Web or transcoding | Native |
| Code generation | Built in for all major languages | Third-party tools over OpenAPI |
| Deadlines | Part of the protocol | Application-defined timeouts |
| HTTP caching | Not applicable | Mature caching and CDN support |
Choose gRPC when you own both ends and need low latency, streaming or cross-language contracts. Choose REST for public APIs, cacheable resources and human-readable traffic; the REST API design guide covers that side. To broadcast events to many browsers, SignalR fits better than either.
Frequently Asked Questions#
Is gRPC faster than REST in ASP.NET Core?#
Usually, for service-to-service traffic. Protobuf messages are smaller and cheaper to serialize than JSON, and HTTP/2 multiplexing avoids per-request connection costs. The gap narrows for large, network-bound payloads, so benchmark your own messages before migrating.
Can I call a gRPC service directly from a browser?#
Not with native gRPC. Use gRPC-Web, which supports unary and server streaming calls from JavaScript or Blazor WebAssembly, or JSON transcoding, which exposes the same methods as REST endpoints. Both run inside your ASP.NET Core app, so no separate proxy is required.
Should I still use the Grpc.Core package?#
No. It has been in maintenance mode since May 2021 and has not shipped since January 2023. The managed packages share most of its API and generated code, so migrating mostly means changing packages and channel or server setup.
Why does all my gRPC traffic go to a single pod in Kubernetes?#
The default service balances TCP connections, and a gRPC client sends every call over one long-lived connection. Use call-level balancing: a service mesh or other L7 proxy, or client-side balancing with the dns:/// resolver, a headless service and the round_robin policy.
How do I document gRPC services the way OpenAPI documents REST APIs?#
Treat the commented .proto file as the primary documentation and publish it with your packages. For interactive exploration, enable gRPC reflection in development and use gRPCurl or gRPCui. Avoid building on the deprecated Swagger package for transcoded APIs.
Summary#
- gRPC in .NET is fully managed and ships as version 2.84.0 for .NET 8, 9 and 10.
.protocontracts drive code generation. Protect them with versioned packages and reserved field numbers.- Four call types cover queries through real-time sessions, as long as you respect the stream threading rules.
- The client factory provides channel reuse, gRPC-aware retries, interceptors, credentials and deadline propagation.
- Precise status codes, mandatory deadlines, gRPC-Web or transcoding for browsers, and call-level load balancing complete a production setup.