← Back to Blog

gRPC Explained: From a .proto File to a Working Rust Server

From a .proto file to a working Rust server: a beginner-friendly guide to gRPC with a hands-on Rust + Tonic proof of concept.

Most developers first meet service-to-service communication through REST and JSON. gRPC takes a different route: you describe your API once in a small contract file, and tools generate the networking code for you. This post explains the idea, walks through the workflow with diagrams, shows what actually travels over the network, and finishes with a compact Rust + Tonic example covering all four gRPC communication patterns.

1. What Is gRPC?

gRPC is an open-source framework for Remote Procedure Calls (RPC). With RPC, one program calls a function that actually runs in another program, possibly on another machine, and the call looks like an ordinary local function call. gRPC uses Protocol Buffers (Protobuf) to define the API and encode messages as compact binary data, and HTTP/2 to carry them over the network.

An analogy. Think of a restaurant. The .proto file is the menu: it lists what you can order and what you get back. The generated client is the waiter who carries your order. The server is the kitchen. Because everyone uses the same menu, nobody misunderstands the order.

Small Correction Worth Knowing

gRPC was created at Google, but the "g" does not officially stand for "Google". The gRPC project gives the "g" a different meaning in every release. "Google Remote Procedure Call" is a popular guess, not the official name.

Diagram: your client code calls get_user(1); the generated client stub sends the request over HTTP/2 as Protobuf to the generated service trait on the server, which calls your get_user logic. The response returns the same way.
Figure 1: What happens when a client calls get_user(1)
  1. Your client code calls get_user(1). It looks like a local call.
  2. The generated client stub turns the request into Protobuf bytes and sends them over HTTP/2.
  3. The server's generated service code decodes the bytes and calls your real get_user() logic.
  4. The response comes back the same way and arrives as a normal return value.

2. Key Terms

TermMeaning
RPCCalling a method that runs in another process or service.
ProtobufA language-neutral format for defining messages and services, and for serializing data compactly.
.proto fileThe shared contract: services, methods, request and response messages, and fields.
StubGenerated client-side code that makes a remote method look like a local one.
HTTP/2The transport gRPC is built on. It carries many independent streams over one connection.
Tonic / ProstTonic is the Rust gRPC framework. Prost encodes and decodes Protobuf messages in Rust.
protocThe Protocol Buffers compiler used during code generation.

3. The Workflow: From Contract to Running Services

The central idea is that both sides are generated from one file. Figure 2 shows the pipeline in a Rust project.

Pipeline diagram: proto/user.proto goes into build.rs and tonic-prost-build, which runs protoc and generates Rust message structs, the UserService server trait, and UserServiceClient. The server in src/main.rs implements the trait and the client in src/bin/client.rs calls it over gRPC on port 50051.
Figure 2: Build-time code generation and run-time communication
  1. Write the contract in proto/user.proto.
  2. Generate code. During cargo build, build.rs calls tonic-prost-build, which runs protoc and produces message structs, a server trait and a client.
  3. Use the generated code. The server implements the UserService trait; the client calls UserServiceClient.
  4. Run. The call is encoded to binary, sent over HTTP/2, decoded by the server, handled and answered.

Because both sides come from the same contract, a mismatch in message shape becomes a compile error instead of a runtime surprise.

4. The Contract and Why Protobuf Is Compact

The POC defines a UserService with five methods. The word stream decides the communication pattern.

syntax = "proto3";
package user;

service UserService {
  // Unary
  rpc GetUser(GetUserRequest) returns (UserResponse);
  rpc CreateUser(CreateUserRequest) returns (UserResponse);
  // Server streaming
  rpc ListUsers(ListUsersRequest) returns (stream UserResponse);
  // Client streaming
  rpc UploadUsers(stream CreateUserRequest) returns (UserResponse);
  // Bidirectional streaming
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

message GetUserRequest    { int32 id = 1; }
message CreateUserRequest { string name = 1; string email = 2; }
message ListUsersRequest  {}
message UserResponse      { int32 id = 1; string name = 2; string email = 3; }
message ChatMessage       { string username = 1; string message = 2; }

Each field has a field number (id = 1, name = 2). On the wire, Protobuf sends the number, not the name. Figure 3 shows the same user record as JSON and as Protobuf; the Protobuf size was checked with the official Protobuf library.

Bar chart: the same user record is 49 bytes as JSON and 26 bytes as Protobuf, with the Protobuf bytes split into field 1 (2 bytes), field 2 (6 bytes) and field 3 (18 bytes).
Figure 3: The same record is 49 bytes as JSON and 26 bytes as Protobuf

Changing a Schema Safely

Safe changeBreaking change
Add a new field with a new numberChange the number of an existing field
Remove a field, then mark its number and name reservedReuse a number that was used before
Rename a field (binary data is unaffected)Change a field to an incompatible type

Old clients ignore fields they do not know about, which is what lets servers and clients upgrade at different times.

5. The Four Communication Patterns

gRPC is not limited to "one request, one response". Figure 4 shows all four patterns.

Four sequence diagrams between a client and a server: unary, server streaming, client streaming, and bidirectional streaming.
Figure 4: Unary, server streaming, client streaming and bidirectional streaming
PatternWhere "stream" appearsPOC resultGood for
UnaryNowhereGetUser, CreateUser: one request, one responseFetch or create one record
Server streamingResponseListUsers: one request, three users streamed backLarge results, live updates
Client streamingRequestUploadUsers: three users sent, one summary backUploads, batches, telemetry
BidirectionalBothChat: three messages sent, a reply for eachChat, real-time collaboration

In bidirectional streaming the two directions are independent. The diagram alternates messages for clarity, but a real server may reply at any time.

6. What Happens on the Wire

Under the hood, a gRPC call is an HTTP/2 exchange. The client sends request headers (including the method path such as /user.UserService/GetUser) and then the message. The server replies with headers, the message, and finally trailers that carry the result in grpc-status. Every call gets its own HTTP/2 stream, and many streams share one connection (Figure 5).

Left: the HTTP/2 frames of one unary call: request headers, request data, response headers, response data, and trailers carrying grpc-status. Right: one HTTP/2 connection carrying several independent streams.
Figure 5: Life of a unary call (left) and many calls on one connection (right)

How the Connection Is Opened: The Handshake

Everything above assumes a connection already exists. Opening one takes three short handshakes, stacked on top of each other (Figure 6). They run once; after that, every call reuses the same connection.

An analogy. Think of a phone call. First you dial and wait for the other person to pick up (TCP). Then you agree on a secret language so nobody listening in can understand you (TLS). Finally you agree on the rules of the conversation, such as how many topics can be discussed at the same time (HTTP/2). After that you can talk about as many topics as you like without dialing again.

Diagram of the gRPC transport handshake in three stages between a gRPC server and a gRPC client. Stage 1, TCP handshake: SYN, SYN-ACK, ACK, giving an established TCP connection. Stage 2, TLS 1.3 handshake: ClientHello with supported versions, ciphers and key shares, ServerHello with the selected cipher, key share and certificate, then Finished messages, giving an encrypted TLS tunnel. Stage 3, HTTP/2 connection handshake: client and server connection prefaces and SETTINGS frames with acknowledgements, giving exchanged and accepted HTTP/2 settings. The payoff is a persistent gRPC channel carrying many multiplexed RPC calls, with the handshake executed once and reused.
Figure 6: The three handshakes that open a gRPC connection (TCP, TLS 1.3, HTTP/2)
StageQuestion it answersResultIn our POC?
1. TCPCan the two machines reach each other reliably?An open connectionYes
2. TLS 1.3Who is the server, and how do we hide the traffic?An encrypted tunnelNo, the POC uses plain http://
3. HTTP/2What are the rules for sending many calls at once?Agreed HTTP/2 settingsYes

Stage 1: TCP Handshake

Three tiny messages open the connection. The client sends SYN ("can we talk?"), the server answers SYN-ACK ("yes, can you hear me?"), and the client confirms with ACK ("yes"). This costs one round trip (RTT): the time for a message to reach the server and for the reply to come back. The "Time (RTTs)" arrow on the left of the diagram shows that each stage happens after the one before it.

Stage 2: TLS 1.3 Handshake

TLS makes the connection private and proves the server is who it claims to be. The client sends a ClientHello listing the TLS versions and ciphers (encryption recipes) it supports, plus its half of a secret key (a key share). For gRPC, the client also uses a TLS extension called ALPN to say "I want to speak h2", the name of HTTP/2. The server replies with a ServerHello that picks one recipe and adds its own key share, and it sends its certificate, which is its proof of identity. Both sides now calculate the same secret key without ever sending it, and the Finished messages confirm that nobody tampered with the handshake. TLS 1.3 needs one extra round trip.

The Diagram Is Simplified

In real TLS 1.3 the certificate travels in encrypted messages right after ServerHello, and ChangeCipherSpec is only a legacy compatibility marker. The details are tidied up in the picture. What matters is the result: after one round trip both sides share the keys, and everything after that is encrypted. Stage 3 is also tidied up: in HTTP/2 the server's connection preface is itself a SETTINGS frame, and the window size is a field inside SETTINGS, not a separate step. In practice the five rows are two SETTINGS frames and two acknowledgements.

Stage 3: HTTP/2 Connection Handshake

Now the two sides agree on the rules of HTTP/2. The client sends a fixed text called the connection preface (it starts with PRI * HTTP/2.0) followed by a SETTINGS frame. The server sends its own SETTINGS frame, and each side acknowledges the other's with a SETTINGS frame marked ACK. These settings cover things such as max concurrent streams (how many calls may run at the same time) and the initial window size (how much data can be sent before the receiver must confirm it). The client does not have to wait: it can send its first request straight after its own preface, so this stage adds no extra round trip.

The Payoff: One Connection, Many Calls

With TLS 1.3, a new secure gRPC connection therefore needs about two round trips before the first request is sent: one for TCP and one for TLS. That cost is paid once per connection. The open connection, called a channel, then carries many calls at the same time as separate HTTP/2 streams, exactly as the right side of Figure 5 shows. In practice this means you should create the client once and reuse it. In Tonic you can clone a client cheaply, and every clone shares the same connection. If you connect again for every call, you pay the handshakes (and the encryption set-up) every time. If a connection is closed, for example after a long idle time or a server restart, the client simply opens a new one and the handshakes run again.

Our POC Skips Stage 2

The client in Section 7 connects to http://[::1]:50051, so there is no TLS and the traffic is not encrypted. That is fine on your own laptop, but a production service must use https:// and a real certificate (see Section 8).

7. Rust + Tonic: A Compact Example

The full POC has five methods; the snippets below keep only what is needed to understand the pattern. You need the protoc compiler installed (for example brew install protobuf or apt install protobuf-compiler).

Check the Rust Version

Recent Tonic releases need a recent Rust toolchain. When we compiled these snippets, tonic 0.14.6 required Rust 1.88 or newer and refused to build on 1.85. If you see an "rustc is not supported" error, run rustup update.

Setup

# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio-stream = "0.1"
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"

[build-dependencies]
tonic-prost-build = "0.14"
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_prost_build::compile_protos("proto/user.proto")?;
    Ok(())
}

Server: Unary and Server Streaming

pub mod user { tonic::include_proto!("user"); }

use user::user_service_server::{UserService, UserServiceServer};
use user::*;
use tonic::{Request, Response, Status};
use tokio_stream::wrappers::ReceiverStream;

#[derive(Default)]
struct MyUserService;

#[tonic::async_trait]
impl UserService for MyUserService {
    // Unary: one request in, one response out
    async fn get_user(&self, req: Request<GetUserRequest>)
        -> Result<Response<UserResponse>, Status> {
        let id = req.into_inner().id;
        if id <= 0 { return Err(Status::invalid_argument("id must be positive")); }
        Ok(Response::new(UserResponse {
            id, name: "Asha".into(), email: "asha@example.com".into(),
        }))
    }

    // Server streaming: send items through a channel
    type ListUsersStream = ReceiverStream<Result<UserResponse, Status>>;
    async fn list_users(&self, _req: Request<ListUsersRequest>)
        -> Result<Response<Self::ListUsersStream>, Status> {
        let (tx, rx) = tokio::sync::mpsc::channel(4);
        tokio::spawn(async move {
            for id in 1..=3 {
                let u = UserResponse { id, name: format!("User {id}"),
                                       email: format!("user{id}@example.com") };
                if tx.send(Ok(u)).await.is_err() { break; }
            }
        });
        Ok(Response::new(ReceiverStream::new(rx)))
    }

    // create_user, upload_users and chat follow the same shape
}

The generated trait requires all five methods, so the real file also contains create_user, upload_users and chat. Start the server like this:

Server::builder()
    .add_service(UserServiceServer::new(MyUserService))
    .serve("[::1]:50051".parse()?)
    .await?;

Client: Call and Consume a Stream

let mut client = UserServiceClient::connect("http://[::1]:50051").await?;

// Unary
let user = client.get_user(GetUserRequest { id: 1 }).await?.into_inner();

// Server streaming: read until the server closes the stream
let mut stream = client.list_users(ListUsersRequest {}).await?.into_inner();
while let Some(user) = stream.message().await? {
    println!("{user:?}");
}

Run the server and client in two terminals, selecting each binary explicitly, for example cargo run --bin <server-name> and cargo run --bin client.

Testing Without Writing a Client

Writing a whole client just to check that the server works is slow. grpcurl is like curl for gRPC: you type one command, it sends a request, and it prints the reply as readable JSON.

There is one catch. A gRPC server only understands binary Protobuf, so grpcurl must know your contract (the menu from Section 1) to turn your JSON into the right bytes. It can learn the contract in two ways:

With the server running, open a second terminal in the project folder. First, ask which methods the contract defines:

grpcurl -plaintext -import-path proto -proto user.proto \
  [::1]:50051 describe user.UserService

Now call the unary method GetUser with the request {"id": 1}:

grpcurl -plaintext -import-path proto -proto user.proto \
  -d '{"id": 1}' [::1]:50051 user.UserService/GetUser
{
  "id": 1,
  "name": "Asha",
  "email": "asha@example.com"
}

Send an invalid id and you can see the error handling from Section 8 at work. The status code and message come straight from Status::invalid_argument:

grpcurl -plaintext -import-path proto -proto user.proto \
  -d '{"id": 0}' [::1]:50051 user.UserService/GetUser
ERROR:
  Code: InvalidArgument
  Message: id must be positive

Streaming methods work the same way. ListUsers takes an empty request, so pass '{}'. The three streamed users arrive one after another, as separate JSON objects:

grpcurl -plaintext -import-path proto -proto user.proto \
  -d '{}' [::1]:50051 user.UserService/ListUsers
{
  "id": 1,
  "name": "User 1",
  "email": "user1@example.com"
}
{
  "id": 2,
  "name": "User 2",
  "email": "user2@example.com"
}
{
  "id": 3,
  "name": "User 3",
  "email": "user3@example.com"
}

For client-streaming and bidirectional methods, use -d @ and type or pipe several JSON objects, one per line.

Part of the commandWhat it does
-plaintextConnect without TLS, because our POC has none (Stage 2 of the handshake is skipped). Without it, grpcurl expects TLS and fails with "first record does not look like a TLS handshake".
-import-path proto -proto user.protoTells grpcurl where the contract file is.
-d '{...}'The request message as JSON. Use '{}' for an empty message.
[::1]:50051The server address. The POC listens on the IPv6 loopback address [::1], so use the same address here. If your server binds 127.0.0.1, use that instead.
user.UserService/GetUserThe method to call, written as package.Service/Method. It is the same path you saw in Section 6 as /user.UserService/GetUser.

Add -v to see the response headers as well. This is a quick way to check a server after every change, before any client code exists.

8. Errors and Security

Tonic callUse it when
Status::invalid_argument(...)The request data is malformed or invalid.
Status::not_found(...)The requested user or resource does not exist.
Status::unauthenticated(...)The caller has not proven who they are.
Status::permission_denied(...)The caller is known but not allowed to do this.
Status::internal(...)An unexpected server-side failure.

The POC Has No Authentication

For production, use TLS to encrypt traffic, send credentials as metadata, and verify them in a Tonic interceptor. Apply authorization per method where needed.

9. gRPC vs REST: How to Choose

AreaRESTgRPC
StyleResources and URLs, like GET /users/1Methods, like GetUser(GetUserRequest)
PayloadUsually JSON (human-readable)Protobuf (compact, binary)
ContractOptional (often OpenAPI)Central: the .proto file
Code generationOptionalCore part of the workflow
StreamingNeeds extra patterns or toolsBuilt in, four patterns
TransportHTTP/1.1 or HTTP/2HTTP/2
BrowsersWork directlyNeed gRPC-Web or a gateway

Figure 7 turns the table into a quick decision rule.

Decision flowchart: if browsers or unknown third parties call the API directly, use REST with JSON or gRPC-Web; otherwise, if you need streaming, low latency or many languages, use gRPC; if not, either works.
Figure 7: A simple way to choose between REST and gRPC

The costs of gRPC are a build step for code generation, binary payloads that are harder to read, and the need to manage schema changes carefully.

10. POC Results and Conclusion

RPCPatternBehavior testedStatus
GetUser()UnaryOne request, one responseCompleted
CreateUser()UnaryOne create request, one responseCompleted
ListUsers()Server streamingOne request, three users streamedCompleted
UploadUsers()Client streamingThree users sent, one summary returnedCompleted
Chat()BidirectionalThree messages, three repliesCompleted

gRPC gives services a precise, typed way to talk: write the contract once, generate the code, and implement only the business logic. With Rust and Tonic the path from user.proto to a running client and server is short, and all four communication patterns work out of the box. For internal APIs and streaming workloads it is a strong choice; for simple public APIs, REST is often easier for consumers.

Official References

← Back to All Articles