How to Use gRPC in Rust (tonic)

Use the tonic crate with Protocol Buffers to define and implement high-performance, asynchronous gRPC services in Rust.

When JSON isn't fast enough

You have a backend service in Rust that processes heavy data. A microservice in Go needs that data, and it needs it fast. You tried HTTP with JSON, but the serialization overhead is eating your latency budget. You're also tired of writing boilerplate parsers and manually mapping fields between languages. You need a contract that both sides share, binary encoding for speed, and HTTP/2 multiplexing so one connection can handle thousands of requests without head-of-line blocking.

That is exactly what gRPC does. In Rust, the tool for the job is tonic. It implements gRPC over HTTP/2 using Protocol Buffers for serialization. You define your service in a .proto file, generate Rust code, and implement the logic. tonic handles the networking, framing, and serialization. You focus on the business rules.

The gRPC contract

gRPC stands for Google Remote Procedure Call. It lets you call a function on a remote server as if it were local. The magic comes from Protocol Buffers, or Protobuf. Think of Protobuf as a shared blueprint language. You write a .proto file that defines your data structures and the functions you want to expose. Both the client and the server read this blueprint.

The compiler generates Rust code from the blueprint. This code includes structs for your messages and traits for your services. You don't write the networking boilerplate yourself. You just implement the trait. tonic takes this generated code and plugs it into an async runtime, usually Tokio, over HTTP/2. HTTP/2 gives you multiplexing, meaning multiple requests can ride on a single TCP connection without blocking each other. This reduces latency and connection overhead significantly compared to HTTP/1.1.

Minimal server

Start with a standard Rust project. You need tonic for the gRPC implementation, prost for the message serialization, and tokio for the async runtime. You also need a build dependency to generate the code from your .proto files.

# Cargo.toml
[dependencies]
tonic = "0.12"
prost = "0.13"
tokio = { version = "1", features = ["full"] }

[build-dependencies]
tonic-build = "0.12"

Create a build.rs file in the root of your project. This script runs during compilation to generate Rust code from your .proto files.

// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Tell tonic-build to look for .proto files in the proto directory.
    // It generates Rust code into the OUT_DIR automatically.
    tonic_build::compile_protos("proto/hello.proto")?;
    Ok(())
}

Create a proto/hello.proto file. This defines your service and messages.

// proto/hello.proto
syntax = "proto3";

package hello;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

Now implement the server in src/main.rs. The tonic::include_proto! macro includes the generated code. You implement the trait that tonic-build created.

// src/main.rs
use tonic::transport::Server;
use tonic::{Request, Response, Status};

// Include the generated code. The path matches the .proto file name.
mod hello {
    tonic::include_proto!("hello");
}

#[derive(Default)]
struct MyGreeter;

/// Implements the Greeter service generated from hello.proto.
#[tonic::async_trait]
impl hello::greeter_server::Greeter for MyGreeter {
    /// Handles the SayHello RPC call.
    async fn say_hello(
        &self,
        request: Request<hello::HelloRequest>,
    ) -> Result<Response<hello::HelloReply>, Status> {
        // Extract the inner message from the tonic Request wrapper.
        // The Request type holds metadata and the message.
        let name = request.into_inner().name;

        // Construct the reply using the generated message type.
        let reply = hello::HelloReply {
            message: format!("Hello {}!", name),
        };

        // Wrap the reply in a tonic Response.
        Ok(Response::new(reply))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Parse the address string into a SocketAddr.
    // This binds to all interfaces on port 50051.
    let addr = "[::]:50051".parse()?;

    // Create the service implementation.
    let greeter = MyGreeter::default();

    // Build the server, add the service, and start listening.
    // serve() blocks until the server is shut down.
    Server::builder()
        .add_service(hello::greeter_server::GreeterServer::new(greeter))
        .serve(addr)
        .await?;

    Ok(())
}

Run cargo run. The server starts and listens on port 50051. It waits for gRPC requests.

Convention aside: The community prefers tonic-build over prost-build for gRPC projects. prost-build only generates message structs. tonic-build wraps prost-build and adds the gRPC server and client traits. Using tonic-build reduces dependency friction and ensures the generated code matches the tonic version.

How the pieces fit

When you run cargo build, the build.rs script executes first. It reads proto/hello.proto and generates Rust code. This code lives in a temporary directory during the build. The tonic::include_proto! macro in main.rs includes this generated code into your module.

At runtime, tokio starts the event loop. Server::builder configures the HTTP/2 transport. add_service registers your MyGreeter implementation. serve binds to the socket and waits for connections.

When a client sends a request, tonic deserializes the binary payload into your HelloRequest struct. It wraps the struct in a Request type that carries metadata like headers and timeouts. tonic calls your say_hello method. You return a Response wrapping your HelloReply. tonic serializes the reply back to binary and sends it over HTTP/2.

Convention aside: Keep unsafe blocks out of gRPC handlers. tonic and prost are safe abstractions. If you find yourself needing unsafe inside a handler, isolate it in a small helper function with a // SAFETY: comment. The handler should focus on logic, not memory management.

Realistic service with validation

Real services need validation and error handling. tonic uses Status for errors, which maps to gRPC status codes. Don't return std::io::Error or panic. Return a Status so the client gets a proper error code.

// src/main.rs (updated handler)
#[tonic::async_trait]
impl hello::greeter_server::Greeter for MyGreeter {
    async fn say_hello(
        &self,
        request: Request<hello::HelloRequest>,
    ) -> Result<Response<hello::HelloReply>, Status> {
        let name = request.into_inner().name;

        // Validate input. Return a proper gRPC error if invalid.
        // Status::invalid_argument maps to the gRPC code INVALID_ARGUMENT.
        if name.is_empty() {
            return Err(Status::invalid_argument("Name cannot be empty"));
        }

        // Check for forbidden characters.
        if name.contains('!') {
            return Err(Status::permission_denied("Name contains forbidden characters"));
        }

        Ok(Response::new(hello::HelloReply {
            message: format!("Hello {}!", name),
        }))
    }
}

The client receives the Status and can inspect the code and message. This allows clients to handle errors programmatically.

Convention aside: Use specific Status codes. Status::internal is for server bugs. Status::invalid_argument is for bad client input. Status::not_found is for missing resources. Choosing the right code helps clients debug issues faster.

Client side

Clients connect to the server using a Channel. tonic generates a client struct for each service. You create the client, build a request, and call the method.

// src/client.rs
use tonic::{Request, transport::Channel};
use hello::greeter_client::GreeterClient;
use hello::HelloRequest;

mod hello {
    tonic::include_proto!("hello");
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Connect to the server.
    // The URL must use http:// even though gRPC uses HTTP/2.
    let mut client = GreeterClient::connect("http://[::]:50051").await?;

    // Create the request message.
    let request = Request::new(HelloRequest {
        name: "Rust".to_string(),
    });

    // Call the RPC.
    let response = client.say_hello(request).await?;

    // Extract the inner message from the response.
    println!("Response: {}", response.into_inner().message);

    Ok(())
}

The client code mirrors the server. You use the generated types and methods. The Channel handles connection pooling and HTTP/2 multiplexing automatically.

Convention aside: Reuse the Channel for multiple requests. Creating a new channel for every request is expensive. The channel maintains a pool of connections. Clone the client struct if you need to call RPCs from multiple tasks. The client struct is cheap to clone.

Pitfalls and compiler errors

If you forget #[tonic::async_trait], the compiler rejects your implementation. You'll see errors about async fn not being supported in traits without the macro. The macro rewrites the trait to use Box<dyn Future> behind the scenes. Always add the attribute to your impl block.

If you hold a Rc inside your service struct, the compiler complains with E0277 (type does not implement Send). gRPC requires all types to be Send because requests can be processed on different threads. Use Arc instead of Rc for shared state.

If your build.rs path is wrong, the generated code won't be found. You'll get a compilation error about missing modules. Check the path in compile_protos and ensure the .proto file exists relative to the project root.

If you try to return a non-Send type from an async handler, you get E0277. Ensure all data inside your handler is Send. This includes closures and captured variables.

Convention aside: Use tonic::Status for all errors. Don't map std::io::Error to Status manually unless necessary. tonic provides Into implementations for common errors. Use ? to propagate errors and let tonic convert them.

Streaming and advanced patterns

gRPC supports streaming. You can send multiple messages in a single call. tonic uses Receiver and Sender from tokio::sync::mpsc for streaming.

For server streaming, the handler returns a Receiver. You send messages into the channel, and the client receives them.

// Example of server streaming signature
async fn list_items(
    &self,
    request: Request<ListRequest>,
) -> Result<Response<tonic::Streaming<HelloReply>>, Status> {
    // Implementation uses a channel to send multiple replies.
    // The client iterates over the Streaming object.
    todo!()
}

For client streaming, the request is a Receiver. You receive messages from the client and process them.

For bidirectional streaming, both request and response are streams. You can read and write simultaneously.

Streaming is useful for large datasets or real-time updates. Use unary calls for simple request-response patterns. Use streaming when you need to transfer many messages or maintain a long-lived connection.

Convention aside: Limit stream sizes. Don't send infinite streams without backpressure. tonic respects backpressure from the channel, but unbounded streams can exhaust memory. Use bounded channels with tokio::sync::mpsc::channel and a reasonable capacity.

Decision matrix

Use tonic when you need high-performance RPC with Protocol Buffers and want idiomatic async Rust. Use reqwest with serde_json when you are building a REST API and prefer human-readable payloads over binary efficiency. Use hyper directly when you need to manipulate HTTP headers or frames at a lower level than gRPC provides. Reach for grpcio only if you are maintaining legacy code; the crate is deprecated in favor of tonic.

Trust the generated code. If the compiler complains about the trait, check your build.rs paths first. The contract is the source of truth.

Where to go next