A microservices architecture splits a system into multiple independently deployed services, which inevitably need to call each other across the network. The problem RPC (remote procedure call) tries to solve is making that cross-network call as easy to write as calling a local function — the caller doesn’t have to manually handle the connection, serialization, or packet format; it just passes arguments and gets a return value, like calling a function. gRPC is one of the most common RPC frameworks today. This article first covers RPC’s basic model, then breaks down exactly how gRPC implements it.
1. The Problem RPC Tries to Solve
1.1 Wrapping a remote call in the appearance of a local one
The basic flow of an RPC call: the caller invokes a “stub” that looks like a local function; the stub serializes the arguments into bytes and sends them over the network to the server; the server receives them, deserializes them, calls the real implementation function, and serializes the return value to send back; the caller’s stub then deserializes the received bytes into a return value and hands it to the caller. The entire process of serialization, transport, and deserialization is hidden from the caller, so writing the call looks like a single line of local code: result = greeter.SayHello(request).
1.2 It can hide the syntax, but not the failure modes
RPC can make the syntax look like a local call, but it can’t make the failure modes match a local call. A local function call almost never “loses contact halfway through,” but a network call might time out, a packet might be lost and retransmitted, or a server might receive a request and crash before the response is delivered — the caller has no way to tell whether “the request never arrived at all” from “the request arrived and ran, but the response was lost.” This is the well-known partial failure problem in distributed systems: the caller can only choose to retry, give up, or make repeated execution safe (idempotent) — there’s no way to make a network call’s failure modes fully equivalent to a local call. Understanding this limitation is a prerequisite for using any RPC framework correctly, not a flaw of the framework itself.
2. How gRPC Actually Works
2.1 Defining the interface with Protocol Buffers
gRPC uses Protocol Buffers (protobuf) as its interface definition language: you first write a .proto file describing which remote methods exist and what each method’s request and response look like.
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
The protoc compiler generates client stubs and server-side skeleton code in multiple languages (Go, Python, Java, C++, and so on) from the same .proto file. This means the interface only needs to be defined once, and services written in different languages will still line up correctly — you won’t get the kind of error common with hand-written serialization code, like “the client thinks the field is a string but the server reads it as an integer.” Every field in a message has a numeric tag (the 1 in string name = 1;), and that number is the field’s real identifier on the wire — as long as new fields use new numbers and don’t reuse a number that’s already been deprecated, old and new versions of clients and servers stay compatible with each other going forward.
2.2 Built on HTTP/2
gRPC’s transport layer is HTTP/2, directly inheriting several of its features: a single TCP connection can multiplex multiple concurrent calls without queuing behind each other the way HTTP/1.1 does; headers are compressed with HPACK, cutting down the bandwidth spent on repeated metadata; and HTTP/2 natively supports bidirectional streaming, which is exactly what lets gRPC implement bidirectional streaming calls (see Section 3).
3. The Four Call Modes
gRPC defines four call modes, differing in whether the request and response are each “one” or “a stream”: Unary is the most basic one-to-one call, one request for one response, matching the intuition of an ordinary function call exactly. Server streaming is one request for a stream of responses — for example, subscribing to updates on some resource, where the server keeps pushing new messages. Client streaming goes the other way — the client sends a stream of requests (for example, uploading data in batches), and the server only sends back one summary response once it’s received everything. Bidirectional streaming lets both sides keep sending messages independently, with neither side needing to wait for the other to finish — suited to real-time interactive scenarios like chat rooms or live speech-to-text. All four modes share the same interface-definition syntax; the only difference is whether the stream keyword is added to annotate the request or response in the rpc declaration.
4. The gRPC-versus-REST Tradeoff
REST (usually paired with JSON) has the advantage of being human-readable, callable directly from any HTTP client, and able to directly use HTTP’s existing caching semantics (for example, GET is inherently cacheable); its downside is that JSON serialization is bulkier and slower to parse, and there’s no enforced type contract, so a client and server disagreeing about a field’s type is usually only discovered at runtime. gRPC uses binary protobuf encoding, which is compact and fast to parse, and its interface contract is enforced by code generated from the .proto file, so a type mismatch is caught at compile time; but a browser can’t issue a native gRPC call directly (it needs an extra proxy layer via gRPC-Web), and unlike JSON, the binary format can’t be read directly by eye, so debugging usually requires extra tooling (such as grpcurl). In practice, public-facing APIs often use REST or GraphQL for compatibility and readability, while internal calls between services more often use gRPC to gain efficiency and type safety.
5. Common Pitfalls
Forgetting to set a deadline. An RPC call doesn’t time out automatically by default — without an actively set deadline, if the network misbehaves or the server hangs, the caller waits indefinitely, and the resources it’s holding (threads, connections) never get released. This is exactly where the partial-failure problem from Section 1.2 bites hardest in practice.
Load balancing breaking down. gRPC multiplexes many logical calls onto the same long-lived connection. If a load balancer only decides which backend to forward to when the TCP connection is first established (as traditional layer-4 load balancing often does), every call on that same connection afterward keeps hitting the same backend, and other backend instances get no traffic at all. The fix is to use a proxy that understands HTTP/2 and load-balances at the individual-call level (such as Envoy), or to implement service discovery and load balancing directly in the client itself.
Changing a field number or type breaks compatibility. protobuf’s compatibility design relies on field numbers staying fixed; reusing a deprecated field number, or changing the type of a field already in use, causes old and new versions to misread each other’s data — without necessarily throwing an immediate error, making it a particularly hard class of version-compatibility bug to track down. Adding new fields while keeping old field numbers unreused is the safe way to evolve a schema.