gRPC: High-Performance Communication for Microservices
A practical guide to gRPC — the high-performance RPC framework built on HTTP/2 and Protocol Buffers, widely used for internal microservice-to-microservice communication, covering service definitions, the four call types, streaming, .NET implementation, and how it compares to REST and GraphQL.
Table of Contents
- Introduction
- Why gRPC Exists
- Protocol Buffers
- Service Definitions
- The Four Call Types
- Building a gRPC Service in .NET
- Deadlines and Cancellation
- Error Handling
- Interceptors
- Authentication and Security
- gRPC-Web and Browser Support
- gRPC vs. REST vs. GraphQL
- Performance Characteristics
- Quick Reference Table
- Conclusion
Introduction
gRPC (originally "gRPC Remote Procedure Calls," now just a name) is an open-source RPC framework created by Google, built on two foundations: HTTP/2 for transport and Protocol Buffers (protobuf) for the interface definition language and wire format. Instead of thinking in terms of resources and HTTP verbs (as in REST), gRPC lets you define services as a set of remote methods that look and feel like local function calls — the framework handles serialization, transport, and networking underneath.
service ProductService {
rpc GetProduct (GetProductRequest) returns (Product);
}
var client = new ProductService.ProductServiceClient(channel);
var product = await client.GetProductAsync(new GetProductRequest { Id = 42 });
That client call looks like an ordinary method invocation, but it's a network call to another service, potentially running in a different language entirely — gRPC is fully polyglot, with generated client and server code for C#, Go, Java, Python, Node.js, Rust, and more, all from the same .proto file.
1. Why gRPC Exists
The problem with JSON-over-HTTP for internal service-to-service calls
REST/JSON is an excellent fit for public APIs — human-readable, universally supported, easy to debug with a browser or curl. But for high-volume internal microservice communication, some of those same strengths become costs:
- Serialization overhead — JSON is text-based; parsing and generating it is slower and more CPU-intensive than a compact binary format.
- No strict contract enforcement — JSON schemas are optional and often drift from actual server behavior over time.
- HTTP/1.1 limitations — one request per TCP connection at a time (without pipelining hacks), meaning many parallel calls need many connections.
- No native streaming — REST is fundamentally request/response; long-lived bidirectional communication needs WebSockets or Server-Sent Events bolted on separately.
gRPC addresses all four: protobuf is a compact binary format with a strict, versioned schema; HTTP/2 provides multiplexed streams over a single connection; and streaming is a first-class part of the service definition itself, not an afterthought.
Where gRPC fits
gRPC is generally the right tool for:
-
Service-to-service communication inside a data center or cluster where you control both ends and can share
.protofiles. - Low-latency, high-throughput internal APIs (e.g., a pricing service called thousands of times per second by other backend services).
- Polyglot systems where different services are written in different languages but need a strict, generated contract between them.
It's generally not the right default for public-facing APIs consumed by arbitrary third parties or browsers directly, where REST's ubiquity, human-readability, and native HTTP caching still win (see Section 11).
2. Protocol Buffers
Protocol Buffers (protobuf) is both the interface definition language and the binary wire format gRPC uses by default.
Defining a message
syntax = "proto3";
message Product {
int32 id = 1;
string name = 2;
double price = 3;
repeated string tags = 4;
optional string description = 5;
}
Each field has a number (= 1, = 2, ...) — this is what actually gets sent over the wire, not the field name. Field numbers, not names, are what makes protobuf both compact and forward/backward compatible.
Why field numbers matter for compatibility
// Version 1
message Product {
int32 id = 1;
string name = 2;
}
// Version 2 — safe, backward-compatible change
message Product {
int32 id = 1;
string name = 2;
double price = 3; // new field, old clients simply ignore it
reserved 4; // a field was removed — reserve its number so it's never reused accidentally
}
Rules of thumb for safe evolution:
- Never change a field's number once it's shipped — that field's identity on the wire is its number, not its name.
-
Never reuse a removed field's number — mark it
reservedinstead. - New fields should be optional (or have a sensible default) so older clients/servers that don't know about them keep working.
- Renaming a field name in the
.protofile is safe (the number is unchanged); renumbering is not.
Binary size and speed
Because protobuf encodes field numbers and types compactly (varints, no repeated field-name strings), a typical protobuf payload is meaningfully smaller than the equivalent JSON, and encoding/decoding is faster since there's no text parsing or reflection-heavy property-name matching involved — one of the main reasons gRPC outperforms JSON-over-HTTP for high-volume internal traffic.
3. Service Definitions
A .proto file defines both messages (data) and services (behavior):
syntax = "proto3";
option csharp_namespace = "Catalog.Grpc";
package catalog;
service ProductService {
rpc GetProduct (GetProductRequest) returns (Product);
rpc ListProducts (ListProductsRequest) returns (stream Product);
rpc CreateProduct (CreateProductRequest) returns (Product);
rpc WatchPriceChanges (stream WatchPriceRequest) returns (stream PriceUpdate);
}
message GetProductRequest {
int32 id = 1;
}
message ListProductsRequest {
string category = 1;
}
message CreateProductRequest {
string name = 1;
double price = 2;
}
message Product {
int32 id = 1;
string name = 2;
double price = 3;
}
This single file is the source of truth: running the protobuf compiler (protoc, or the MSBuild integration in .NET) generates strongly-typed client and server base classes in whatever target language you need — C#, Go, Python, Java, and more — all guaranteed to agree on the wire format.
4. The Four Call Types
gRPC supports four RPC patterns, distinguished by whether the request and/or response is a single message or a stream.
Unary (request-response — the REST-like default)
rpc GetProduct (GetProductRequest) returns (Product);
var product = await client.GetProductAsync(new GetProductRequest { Id = 42 });
One request, one response — functionally similar to a REST GET call.
Server streaming
rpc ListProducts (ListProductsRequest) returns (stream Product);
using var call = client.ListProducts(new ListProductsRequest { Category = "electronics" });
await foreach (var product in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine(product.Name);
}
The server sends back a stream of messages over time (e.g., results as they're found, or live progress updates), and the client reads them as they arrive rather than waiting for one giant response.
Client streaming
rpc UploadMetrics (stream MetricPoint) returns (UploadSummary);
using var call = client.UploadMetrics();
foreach (var point in metrics)
{
await call.RequestStream.WriteAsync(point);
}
await call.RequestStream.CompleteAsync();
var summary = await call.ResponseAsync;
The client sends a stream of messages (e.g., telemetry data points, chunks of a large file) and gets a single summary response once it's done.
Bidirectional streaming
rpc WatchPriceChanges (stream WatchPriceRequest) returns (stream PriceUpdate);
using var call = client.WatchPriceChanges();
var readTask = Task.Run(async () =>
{
await foreach (var update in call.ResponseStream.ReadAllAsync())
Console.WriteLine($"{update.ProductId}: {update.NewPrice}");
});
await call.RequestStream.WriteAsync(new WatchPriceRequest { ProductId = 42 });
await call.RequestStream.CompleteAsync();
await readTask;
Both sides send and receive messages independently over the same long-lived connection — ideal for real-time, chat-like, or continuously negotiating protocols, and something HTTP/1.1-based REST has no clean native equivalent for.
5. Building a gRPC Service in .NET
Project setup
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.*" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos/product.proto" GrpcServices="Server" />
</ItemGroup>
Implementing the service
The .proto compiler generates a ProductServiceBase class; you override its methods:
public class ProductGrpcService : ProductService.ProductServiceBase
{
private readonly IProductRepository _repo;
public ProductGrpcService(IProductRepository repo) => _repo = repo;
public override async Task<Product> GetProduct(GetProductRequest request, ServerCallContext context)
{
var product = await _repo.GetByIdAsync(request.Id);
if (product is null)
throw new RpcException(new Status(StatusCode.NotFound, $"Product {request.Id} not found"));
return new Product { Id = product.Id, Name = product.Name, Price = (double)product.Price };
}
public override async Task ListProducts(
ListProductsRequest request,
IServerStreamWriter<Product> responseStream,
ServerCallContext context)
{
await foreach (var product in _repo.StreamByCategoryAsync(request.Category, context.CancellationToken))
{
await responseStream.WriteAsync(new Product { Id = product.Id, Name = product.Name, Price = (double)product.Price });
}
}
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
var app = builder.Build();
app.MapGrpcService<ProductGrpcService>();
app.MapGet("/", () => "This server only accepts gRPC over HTTP/2."); // gRPC-only servers often expose a simple landing page
app.Run();
Calling it from a client
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new ProductService.ProductServiceClient(channel);
var product = await client.GetProductAsync(new GetProductRequest { Id = 42 });
Console.WriteLine($"{product.Name}: {product.Price:C}");
The client class (ProductServiceClient) is entirely generated from the .proto file — there's no hand-written HTTP client code, URL construction, or manual JSON (de)serialization.
6. Deadlines and Cancellation
Every gRPC call should carry an explicit deadline — the maximum time the caller is willing to wait — which propagates automatically to any downstream calls the server makes on the caller's behalf.
var deadline = DateTime.UtcNow.AddSeconds(5);
var product = await client.GetProductAsync(
new GetProductRequest { Id = 42 },
deadline: deadline);
public override async Task<Product> GetProduct(GetProductRequest request, ServerCallContext context)
{
// context.CancellationToken is automatically triggered when the deadline is exceeded
var product = await _repo.GetByIdAsync(request.Id, context.CancellationToken);
return MapToProto(product);
}
This is one of gRPC's underrated strengths for microservice architectures: because deadlines are a core part of the protocol (not an application-level convention), a chain of service A → B → C can propagate a single end-to-end deadline automatically, preventing a slow downstream call from silently holding resources long after the original caller has given up.
7. Error Handling
gRPC uses a defined set of status codes (distinct from, and much smaller than, HTTP's status code list) to represent outcomes:
| Status code | Meaning | Rough REST equivalent |
|---|---|---|
OK |
Success | 200 |
INVALID_ARGUMENT |
Malformed request | 400 |
UNAUTHENTICATED |
Missing/invalid credentials | 401 |
PERMISSION_DENIED |
Authenticated but not allowed | 403 |
NOT_FOUND |
Resource doesn't exist | 404 |
ALREADY_EXISTS |
Conflict on create | 409 |
RESOURCE_EXHAUSTED |
Rate limit / quota exceeded | 429 |
FAILED_PRECONDITION |
Operation not valid in current state |
409/422
|
DEADLINE_EXCEEDED |
Call took too long | 504 |
UNAVAILABLE |
Server temporarily down | 503 |
INTERNAL |
Unhandled server error | 500 |
throw new RpcException(new Status(StatusCode.NotFound, $"Product {request.Id} not found"));
try
{
var product = await client.GetProductAsync(new GetProductRequest { Id = 999 });
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
Console.WriteLine("Product doesn't exist.");
}
Rich error details
For structured error payloads beyond a plain status + message, gRPC supports attaching typed error details (via the google.rpc.Status and error_details.proto conventions) — the gRPC equivalent of REST's ProblemDetails, letting a client programmatically inspect why a call failed, not just parse a free-text message.
8. Interceptors
Interceptors are gRPC's equivalent of ASP.NET Core middleware or REST endpoint filters — cross-cutting logic that wraps every call.
public class LoggingInterceptor : Interceptor
{
private readonly ILogger<LoggingInterceptor> _logger;
public LoggingInterceptor(ILogger<LoggingInterceptor> logger) => _logger = logger;
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
_logger.LogInformation("Handling {Method}", context.Method);
try
{
return await continuation(request, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in {Method}", context.Method);
throw;
}
}
}
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<LoggingInterceptor>();
});
Common uses: logging, metrics/tracing, authentication checks, request validation, and automatic retry policies on the client side.
9. Authentication and Security
TLS by default
gRPC is designed around TLS-secured connections (https:// channels) as the default expectation, since HTTP/2 in most environments requires TLS in practice; plaintext (h2c) is typically reserved for trusted internal networks or local development.
Token-based auth per call
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request, ClientInterceptorContext<TRequest, TResponse> context, ...)
{
var headers = context.Options.Headers ?? new Metadata();
headers.Add("Authorization", $"Bearer {_tokenProvider.GetToken()}");
// ... attach headers and continue the call
}
[Authorize]
public class ProductGrpcService : ProductService.ProductServiceBase
{
// ASP.NET Core's [Authorize] attribute works directly on gRPC service classes
}
Mutual TLS (mTLS) for service-to-service trust
In many microservice deployments, especially inside a service mesh (Istio, Linkerd), both client and server present certificates to each other (mutual TLS) rather than relying solely on bearer tokens — establishing strong, cryptographic service identity at the transport layer itself, often managed transparently by the mesh's sidecar proxies rather than application code.
10. gRPC-Web and Browser Support
Browsers can't speak raw gRPC directly — they lack the low-level HTTP/2 trailer support gRPC relies on for status codes and streaming. gRPC-Web is a variant protocol (translated through a proxy like Envoy, or handled directly by ASP.NET Core's gRPC-Web middleware) that lets browser JavaScript talk to gRPC services:
app.UseGrpcWeb();
app.MapGrpcService<ProductGrpcService>().EnableGrpcWeb();
gRPC-Web supports unary and server-streaming calls from the browser, but not client-streaming or full bidirectional streaming — a real limitation compared to native gRPC, which is one reason gRPC is still mostly an internal, service-to-service technology rather than a browser-facing one; REST or GraphQL remain the more common choice when the browser is a direct client.
11. gRPC vs. REST vs. GraphQL
| Aspect | REST | GraphQL | gRPC |
|---|---|---|---|
| Wire format | JSON (typically) | JSON (typically) | Protobuf (binary) |
| Transport | HTTP/1.1 or HTTP/2 | HTTP/1.1 or HTTP/2 | HTTP/2 (required) |
| Contract | Loose (OpenAPI optional) | Strong (GraphQL schema) | Strong (.proto files) |
| Human-readable payloads | Yes | Yes | No (binary) |
| Browser-native support | Excellent | Excellent | Limited (needs gRPC-Web) |
| Streaming | Not native (needs WebSockets/SSE) | Subscriptions (via WebSockets) | Native, all four call types |
| Codegen | Optional (via OpenAPI) | Common (via schema) | Mandatory, core to the workflow |
| Typical use case | Public APIs, simple CRUD | Client-driven data fetching, multiple client shapes | Internal microservice-to-microservice calls |
| HTTP caching | Native | Limited | Not applicable (not cache-friendly by design) |
| Performance (throughput/latency) | Good | Good | Best — compact binary payloads, multiplexed HTTP/2 |
A realistic architecture combining all three
A common pattern in larger systems: gRPC between internal microservices (fast, strongly-typed, low overhead), a GraphQL or REST gateway/BFF layer facing web and mobile clients (aggregating multiple internal gRPC calls into one client-friendly response), and plain REST for public, third-party-facing APIs and webhooks where broad compatibility and human-readability matter more than raw throughput.
12. Performance Characteristics
- Smaller payloads — protobuf's binary encoding is typically significantly more compact than the equivalent JSON, especially for numeric-heavy or deeply nested data, since it avoids repeating field names and uses variable-length integer encoding.
- Faster (de)serialization — no text parsing, no reflection-based property matching by name; protobuf uses generated, statically-typed serialization code.
- HTTP/2 multiplexing — many concurrent gRPC calls share a single TCP connection without head-of-line blocking at the application layer, avoiding the connection-per-request overhead more common with HTTP/1.1-based REST clients.
- Streaming avoids buffering — server-streaming responses let a client start processing the first result before the last one has even been produced, rather than waiting for a single, fully-buffered JSON array.
-
Codegen eliminates a class of bugs — because client and server are generated from the same
.protofile, an entire category of "the client sent the wrong field name" or "the server changed a field's type silently" bugs simply can't happen the way they can with loosely-typed JSON.
The tradeoff: protobuf payloads aren't human-readable on the wire (harder to debug with a plain packet capture or curl), and the tight compile-time coupling between .proto files and generated code means schema changes require regenerating and redeploying client code — a real cost REST's "just add a field, old clients ignore it" flexibility avoids more gracefully in loosely-coupled, publicly-consumed APIs.
Quick Reference Table
| Concept | Purpose |
|---|---|
.proto file |
Single source of truth for messages, services, and generated code |
| Field numbers | Wire identity of a field — never reuse or renumber |
| Unary call | Standard request/response, like REST GET/POST
|
| Server streaming | One request, many responses over time |
| Client streaming | Many requests, one final response |
| Bidirectional streaming | Both sides send/receive independently over one connection |
| Deadlines | Propagate a call's max wait time through the whole chain |
| Status codes | Small, standardized outcome set (NOT_FOUND, UNAVAILABLE, etc.) |
| Interceptors | Cross-cutting logic (logging, auth, metrics) around every call |
| mTLS | Mutual certificate-based trust between services |
| gRPC-Web | Browser-compatible subset (no client/bidi streaming) |
Conclusion
gRPC trades JSON's universal readability for protobuf's speed, compactness, and strict contract enforcement — a trade that pays off enormously for high-volume, internal microservice-to-microservice traffic where both ends are under your control and every millisecond and byte matters. Native support for all four call shapes (including true bidirectional streaming), built-in deadline propagation, and generated, type-safe clients across languages solve real operational pain points that REST and even GraphQL don't address as directly.
It's rarely the right choice for public, browser-facing, or loosely-coupled third-party APIs — REST's ubiquity and human-readability, or GraphQL's client-driven flexibility, usually win there. The strongest architectures tend to use each tool where it's actually strongest: gRPC inside the service mesh, REST or GraphQL at the edge facing the outside world.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the field-numbering mistake that taught you to respect protobuf compatibility rules.
Top comments (0)