DEV Community

Cover image for gRPC: High-Performance Communication for Microservices
Rhuturaj Takle
Rhuturaj Takle

Posted on

gRPC: High-Performance Communication for Microservices

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

  1. Introduction
  2. Why gRPC Exists
  3. Protocol Buffers
  4. Service Definitions
  5. The Four Call Types
  6. Building a gRPC Service in .NET
  7. Deadlines and Cancellation
  8. Error Handling
  9. Interceptors
  10. Authentication and Security
  11. gRPC-Web and Browser Support
  12. gRPC vs. REST vs. GraphQL
  13. Performance Characteristics
  14. Quick Reference Table
  15. 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);
}
Enter fullscreen mode Exit fullscreen mode
var client = new ProductService.ProductServiceClient(channel);
var product = await client.GetProductAsync(new GetProductRequest { Id = 42 });
Enter fullscreen mode Exit fullscreen mode

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 .proto files.
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 reserved instead.
  • 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 .proto file 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
var product = await client.GetProductAsync(new GetProductRequest { Id = 42 });
Enter fullscreen mode Exit fullscreen mode

One request, one response — functionally similar to a REST GET call.

Server streaming

rpc ListProducts (ListProductsRequest) returns (stream Product);
Enter fullscreen mode Exit fullscreen mode
using var call = client.ListProducts(new ListProductsRequest { Category = "electronics" });
await foreach (var product in call.ResponseStream.ReadAllAsync())
{
    Console.WriteLine(product.Name);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
using var call = client.UploadMetrics();
foreach (var point in metrics)
{
    await call.RequestStream.WriteAsync(point);
}
await call.RequestStream.CompleteAsync();
var summary = await call.ResponseAsync;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 });
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode
try
{
    var product = await client.GetProductAsync(new GetProductRequest { Id = 999 });
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
    Console.WriteLine("Product doesn't exist.");
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<LoggingInterceptor>();
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
[Authorize]
public class ProductGrpcService : ProductService.ProductServiceBase
{
    // ASP.NET Core's [Authorize] attribute works directly on gRPC service classes
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 .proto file, 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)