gRPC is a contract-first way for services to call each other over HTTP/2 using Protocol Buffers as the serialization format. REST and HttpClient require stitching URLs, JSON shapes, and status codes together by hand. gRPC starts from a .proto file that describes the messages and the methods, and the tooling generates the C# classes for both the client and the server. This lesson covers what gRPC is, how the code generation pipeline works in .NET, how to build a server with ASP.NET Core, how to call it from a client, the four kinds of RPC the protocol supports, and how it compares to REST.
gRPC is a remote procedure call (RPC) framework. The model is "call a method on a remote service the same way as a local method." A service is defined in a .proto file, the tooling generates client and server stubs, and the code calls client.GetProductAsync(request) as if GetProduct lived in the same process. The framework handles serialization, transport, error mapping, and streaming.
Two pieces make gRPC what it is. The transport is HTTP/2, which gives it multiplexing (many calls share one connection), binary framing, header compression, and full-duplex streaming over a single socket. The payload format is Protocol Buffers, a compact binary serialization format with a strict schema. The combination produces messages that are small on the wire, fast to parse, and impossible to misinterpret because the schema is shared between client and server.
The shape above is the same every time gRPC is used. The application code talks to a generated stub. The stub serializes the request into Protocol Buffers bytes and sends them over an HTTP/2 channel. On the server side, the framework deserializes the bytes back into a strongly typed request, calls the matching method on the service class, takes the response object returned, and sends it back. There's no JSON to write, no URL routing to design, no status codes to invent.
gRPC sits at a different layer from REST. As lesson 02 covered, REST treats services as collections of resources behind URLs, with JSON as the standard payload and HTTP verbs as the operations. gRPC treats services as collections of methods, with binary payloads, and uses HTTP/2 as a transport. gRPC is also HTTP, but it's contract-first instead of URL-first.
The framework targets one specific job: internal service-to-service communication. Two backend services talking to each other in the same cluster, a mobile app talking to its backend, a microservice fan-out where one request triggers ten downstream calls. For those scenarios gRPC's combination of small payloads, generated clients, and streaming is hard to beat.
Five reasons gRPC shows up in modern .NET architectures, worth understanding before writing any .proto files. The reasons aren't "gRPC is fast" in the abstract; they're specific properties that fall out of the design.
The first is payload size. Protocol Buffers encodes fields by number, not by name, and uses variable-length integers and packed encodings for repeated values. A Product message with an integer id, a string name, and a decimal price typically serializes to under 30 bytes in protobuf, where the equivalent JSON might be 80 to 120 bytes once field names, quotes, and whitespace are counted. Over millions of calls per day, the difference adds up.
The second is strong typing across services. Both sides of the wire share the same .proto file. If the server adds a required field and the client wasn't regenerated, the type mismatch surfaces at compile time on whichever side hasn't caught up. With REST, that same change would silently send a JSON property the other side ignores, and the bug would surface in production as missing data.
The third is generated clients. Instead of HttpClient.GetAsync("/products/" + id) and deserializing a JSON response into a DTO, the call is client.GetProductAsync(new GetProductRequest { Id = id }). The compiler enforces the request shape, the IDE autocompletes method names, and refactoring a field name on one side ripples to the other.
The fourth is streaming. gRPC supports four call patterns: unary (one request, one response), server streaming (one request, many responses), client streaming (many requests, one response), and bidirectional streaming. All four work over a single HTTP/2 connection. REST has no equivalent without inventing something on top, like Server-Sent Events or long polling.
The fifth is tooling. The .NET integration is first-class. The Grpc.Tools NuGet package wires code generation into dotnet build. ASP.NET Core has a native MapGrpcService<T>() extension. Observability tools (OpenTelemetry, gRPC reflection, gRPC health checks) plug in without extra ceremony. Visual Studio shows the generated types alongside the project's own.
The trade-off is that gRPC is harder to debug than REST. curl cannot hit a gRPC endpoint, browser developer tools won't show the request body in a readable form, and the binary payload is meaningless until decoded. Tools like grpcurl exist, but they're not as ubiquitous as the JSON-over-HTTP toolchain.
Protocol Buffers serialization is roughly 5 to 10 times faster than JSON for typical payloads, and the encoded size is 30 to 50 percent of the JSON equivalent. The win comes from skipping property names, using binary integers instead of decimal strings, and avoiding the overhead of allocating field-name strings during parsing.
Everything in gRPC starts with a .proto file. The file describes two things: the messages that flow over the wire, and the services that group RPC methods together. The syntax is small and intentional. There are no expressions, no logic, just type declarations.
The first line pins the syntax version. proto3 is the current standard, used in every new gRPC project. The csharp_namespace option tells the .NET code generator where to put the generated classes. Without it, the generator falls back to a name derived from the package.
The service block groups RPC methods. Each rpc line declares one method: its name, its input message type, and its output message type. The Products service has two methods, GetProduct and ListProducts. Both take one message and return one message, which makes them unary RPCs. Streaming methods use the stream keyword.
The message blocks define the data shapes. Each field has a type, a name, and a field number. The field number is what protobuf puts on the wire. The name is only for the generated code. The numbers must be unique within a message and never reused for a different field, even if a field is renamed or removed later. Numbers 1 through 15 use one byte on the wire; 16 through 2047 use two bytes. Place the most frequently serialized fields in the low range.
The repeated keyword means "list of." repeated Product products = 1; is a list of Product messages and generates as RepeatedField<Product> on the .NET side, which behaves like a normal collection.
Protobuf scalar types map to .NET types as follows.
| Protobuf Type | .NET Type | Notes |
|---|---|---|
int32, int64 | int, long | Variable-length encoded |
uint32, uint64 | uint, ulong | Unsigned variants |
float, double | float, double | IEEE 754 floating point |
bool | bool | One byte on the wire |
string | string | UTF-8 encoded |
bytes | ByteString | Raw binary, not byte[] |
repeated T | RepeatedField<T> | List-like collection |
The mismatch with .NET's decimal matters here. Protobuf has no native decimal type. For exact monetary math, teams usually encode prices as int64 representing cents, or define a custom Decimal message with separate units and nanos fields. double works fine for non-monetary measurements but has the usual floating-point pitfalls when a price field stored as double round-trips through code that expects exact decimal arithmetic.
Field numbers under 16 fit in a single byte on the wire (tag plus wire type). Numbers 16 through 2047 take two bytes. For frequently used fields in a high-throughput message, keep the numbers low; for fields added later, start at 16 or higher to leave room.
The .proto file is just text. To make it usable from C#, the protobuf compiler (protoc) reads it and emits .NET source files: a class per message, a base class per service for the server, and a client class per service for the caller. In a .NET project, protoc is rarely run directly. Instead, add the Grpc.Tools NuGet package, declare .proto files as <Protobuf> items in the csproj, and dotnet build does the generation as part of the build.
A minimal server csproj looks like this.
The <Protobuf> item is the key line. Include points at the .proto file relative to the project. GrpcServices="Server" tells the generator to emit the server-side base class but skip the client stub. A client project uses GrpcServices="Client", and a project that contains both client and server code (or a shared library both reference) uses GrpcServices="Both".
The Grpc.AspNetCore package pulls in everything required on the server side: the protobuf runtime, the ASP.NET Core integration, and the code generators. No separate Grpc.Tools reference is required, because Grpc.AspNetCore depends on it.
A client csproj is almost identical, with two differences: it's not a web project, and GrpcServices="Client".
The PrivateAssets="All" on Grpc.Tools means the package only runs at build time and doesn't end up as a transitive dependency of anything that references this project. It's a build-time tool, not a runtime library, and the dependency graph reflects that.
On build, Grpc.Tools runs protoc against each <Protobuf> item and writes the generated source into the obj/ directory. The compiler picks up those files as if they were hand-written. Right-clicking "Go to Definition" on a generated class in Visual Studio opens the generated .cs file. The generated code is regenerated every build, so don't edit it directly. To add behavior to a generated class, write a partial class in a separate folder; all generated message and service classes are partial.
The pipeline runs every build. Edit the .proto file, hit build, and the generated classes update with no manual step. That tight loop is most of the productivity story behind gRPC in .NET.
Once the code is generated, the server side has two parts: a class that inherits from the generated base and implements each RPC, and a registration line in Program.cs that wires the class into ASP.NET Core's routing.
For the Products service defined above, the generator emits a Products.ProductsBase class. The base class has a virtual method for each RPC declared in the .proto. Override the ones to implement.
Two things to note. The base class is Products.ProductsBase, named after the service in the .proto file. Each override takes the request message and a ServerCallContext, which carries metadata about the call (the deadline, the cancellation token, the peer address, any custom headers the client sent). The return type is Task<TResponse> because gRPC is async end-to-end. When the work is synchronous, like the lookup above, wrap the response in Task.FromResult to satisfy the signature. The _Task & Task<T>_ lesson covers Task.FromResult for already-known values in detail.
The RpcException thrown on the not-found path is the standard way to signal an error. The Status carries a StatusCode enum value and a human-readable message. On the client side, the same exception type surfaces with the same code, so callers can react to errors with strongly typed status checks. The full status code list is covered below.
The Program.cs for the server registers gRPC and maps the service.
AddGrpc() registers the framework services. MapGrpcService<ProductsService>() wires the service into the request pipeline. The route is derived from the service name in the .proto file, so the endpoint is /products.Products/GetProduct (the package followed by the service name, then the method). The MapGet("/") is a friendly hint, since opening the root in a browser would otherwise return a 415 about unsupported media types.
One thing to be aware of: gRPC over HTTP/2 with plaintext requires extra setup. In development, Kestrel listens on HTTPS by default and HTTP/2 negotiates over TLS through ALPN. In production behind a reverse proxy, HTTP/2 may need to be configured explicitly. The ASP.NET Core docs cover the configurations.
HTTP/2 multiplexing means a single TCP connection can carry many concurrent gRPC calls. With HTTP/1.1 (REST's usual transport), each call typically gets its own connection until HTTP/2 is negotiated, which adds TLS handshake overhead per call. For a service making hundreds of calls per second, the difference shows up as both lower latency and less socket exhaustion.
The client project has the same .proto file (often shared through a class library both sides reference) and GrpcServices="Client" in the csproj. The generator emits a Products.ProductsClient class with one method per RPC.
GrpcChannel.ForAddress builds the HTTP/2 channel to the server. The channel is the gRPC equivalent of an HttpClient: it owns the connection pool, manages keep-alives, and should be reused across calls rather than created per request. One channel per remote service per process is the standard shape. The using declaration disposes the channel when the program exits.
The ProductsClient is constructed against the channel. Calling GetProductAsync returns a Task<GetProductResponse>, like any other async method. There's also a synchronous GetProduct overload that blocks the calling thread until the response arrives. Prefer the async version everywhere except in throwaway scripts.
The generator emits two return types per unary RPC. GetProductAsync returns AsyncUnaryCall<GetProductResponse>, which is awaitable directly (yielding the response) but also exposes the response headers, status, and trailing metadata through properties. The two shapes coexist.
await call and await call.ResponseAsync are equivalent for getting the response. The call object also allows canceling the call, setting deadlines, and inspecting metadata. The using declaration disposes the call, which releases any underlying resources and cancels in-flight work if it hasn't completed.
For typical caller code, the short form is sufficient.
Use the longer form only when headers, trailers, or fine-grained call control are needed.
The Products field on ListProductsResponse is a RepeatedField<Product>, which implements IList<Product> and IEnumerable<Product>. Iterate it like any normal collection.
gRPC supports four call patterns. They differ in whether the client sends one request or many, and whether the server sends one response or many. The same .proto file can mix all four kinds across different methods.
Unary is what every example so far has shown. It's the most common pattern, used about 80 percent of the time. The other three exist for scenarios where the natural shape of the data isn't one-in-one-out.
The .proto declaration looks like this.
Server implementation:
Client call:
One request, one response, awaitable, simple. The framework takes care of serialization, transport, deserialization, and error mapping.
Server streaming fits cases when one request triggers a sequence of responses. Product feeds, log tailing, search results that arrive in pages, anything where the server has more to say than fits in a single message.
The .proto adds the stream keyword to the return type.
Server side: instead of returning one response, write each response to an IServerStreamWriter<T> and the method returns when done.
Client side: the call returns an object whose ResponseStream is an IAsyncStreamReader<Product>. Iterate it with await foreach (or the equivalent MoveNext loop in older C# versions).
Each WriteAsync on the server flushes one message across the HTTP/2 stream. The client sees them arrive in order. The framework closes the stream when the server method returns.
Client streaming flips the direction. The client sends many messages and the server returns one final response after consuming the stream. Uploading a batch of orders, sending a series of inventory adjustments, anything where the input is a sequence and the output is a summary.
The server reads from IAsyncStreamReader<T> until the client closes the stream, then returns the response.
The client writes each order through the call's RequestStream, then calls CompleteAsync to signal it's done.
The single response arrives after the server has consumed every input message and the server method has returned.
Both sides stream independently. Chat, real-time inventory updates with two-way subscriptions, live order tracking where the client sends position updates and the server sends route changes.
The server reads from a request stream and writes to a response stream concurrently. The two streams are independent; neither side has to wait for the other to send before sending.
The same shape works on the client: a producer task writes inventory events into the request stream while a consumer task reads alerts from the response stream.
Bidirectional streaming over a single HTTP/2 stream is one of gRPC's standout features and has no direct REST equivalent. As lesson 06 covered, WebSockets provide bidirectional messaging too, but they're a different protocol with their own framing and message format. gRPC builds the same capability into the same contract-first model as the rest of the framework, with the same generated stubs and the same IAsyncStreamReader<T> and IServerStreamWriter<T> shapes.
Each streaming call uses one HTTP/2 stream within a shared connection, which is much cheaper than opening a separate TCP connection per call. A single HTTP/2 connection can carry hundreds of concurrent streams, and the per-stream overhead is dominated by the messages themselves rather than the channel setup.
Every gRPC call ends with a status. A successful call gets StatusCode.OK. Any other status is an error, and the client receives an RpcException carrying the status code and message the server returned.
The StatusCode enum has a fixed set of values that map to the gRPC standard. The most common ones:
| Status Code | Meaning | Typical Cause |
|---|---|---|
OK | Success | Normal completion |
NotFound | Resource missing | Lookup by id, no row found |
InvalidArgument | Bad request | Validation failed on input |
AlreadyExists | Duplicate create | Unique key already taken |
PermissionDenied | Authorization failed | Caller lacks permission for the action |
Unauthenticated | Missing or invalid credentials | No token, expired token |
Unavailable | Server can't be reached | Network blip, service restarting |
DeadlineExceeded | Call took too long | Client deadline elapsed before response |
Cancelled | Caller canceled | Cancellation token tripped |
Internal | Server-side bug | Unhandled exception in the server method |
Throwing RpcException from a server method is the standard way to return a non-OK status. The framework translates an unhandled .NET exception into StatusCode.Unknown with a message that includes the exception type, which leaks implementation detail. Convert expected error paths into explicit RpcException throws.
The pattern matches HTTP status codes one level up: the server picks the right code, the client matches on the code, the message gives detail for humans. The difference from REST is that the codes are part of the contract, not just a convention. Every gRPC implementation in every language agrees on the same set.
For carrying structured error details (field names, retry information, error type tags), the Metadata on the RpcException can hold key-value pairs, and the gRPC-rich-error model uses a google.rpc.Status message for richer payloads. Those are beyond this lesson; for now, treat status code plus message as the standard contract.
Both styles work for service-to-service communication, and both are used heavily in production .NET stacks. Which one fits depends on what is being optimized for.
| Dimension | gRPC | REST |
|---|---|---|
| Payload format | Protocol Buffers (binary) | JSON (text) |
| Payload size | Smaller (30 to 50 percent of JSON) | Larger |
| Transport | HTTP/2 required | HTTP/1.1 or HTTP/2 |
| Contract | .proto file, generated stubs both sides | OpenAPI or convention, often handwritten |
| Strong typing | Enforced at compile time | Depends on tooling and discipline |
| Streaming | Built-in (server, client, bidirectional) | Not native (SSE or WebSocket bolt-ons) |
| Browser support | Requires grpc-web | Native |
| Debugging | Needs grpcurl, decoded payloads | curl, browser dev tools, postman |
| Best fit | Internal microservices, mobile backends | Public APIs, browser clients, third-party integrations |
The split most teams land on: gRPC for service-to-service traffic inside the cluster, REST for anything a browser or third-party developer will call. The reasons rarely come down to raw performance. They come down to who consumes the API and how. A browser can hit a REST endpoint with fetch; it can't hit a raw gRPC endpoint at all without a grpc-web proxy in front. A public API benefits from JSON's transparency; an internal service benefits from .proto's strict contract.
A few cases where gRPC is the wrong choice.
Public browser-facing APIs without grpc-web. Browsers don't speak gRPC over HTTP/2 the way the framework expects. The grpc-web variant exists for this case, but it requires a proxy (typically Envoy or the grpc-web ASP.NET Core middleware) and loses some features like client streaming. For a public API meant to be called from JavaScript, REST or GraphQL is usually less friction.
Third-party integrations where partners expect REST. "Here's our REST API" is a phrase third parties know what to do with. "Here's our .proto file" requires them to integrate Grpc.Tools or its equivalent in their language. For an API surface meant to be consumed by other companies, sticking to REST removes a lot of onboarding friction.
Quick prototypes. A new service that might not exist next week probably doesn't need a contract-first format and a code-generation pipeline. Start with Map endpoints returning JSON, and only adopt gRPC when the service has stable consumers that benefit from the strict contract.
Heavy debugging with command-line tools. If most of the operational workflow is "curl this endpoint, look at the JSON, restart the service," REST keeps that workflow. With gRPC, decoding binary payloads or learning grpcurl is a real cost.
For everything else, especially backend-to-backend traffic in microservice architectures, gRPC is hard to beat on the combination of payload size, type safety, and streaming support.
Adding gRPC to a service adds a code-generation pipeline to the build, a binary protocol to the operational toolchain, and a separate contract artifact to maintain. The wins are real for chatty internal calls; they're often not worth it for a service with three RPCs called once an hour.
10 quizzes