When functions need to cross process boundaries
You're building a payment service. The frontend talks to a Go API gateway. The gateway needs to call your Rust backend to process a transaction. You could write JSON endpoints, parse strings, hope the schema matches, and debug serialization errors at 3 AM. Or you could define a contract once, generate the code, and let the compiler enforce the types on both sides. That's gRPC. In Rust, tonic is the standard way to do this. It wraps the raw gRPC protocol in async Rust types that feel native.
The contract-first approach
gRPC stands for Google Remote Procedure Call. It lets programs call functions on other machines as if they were local. Under the hood, it uses HTTP/2 for transport and Protocol Buffers for serialization. Protocol Buffers (protobuf) is a language-neutral way to define data structures. You write a .proto file, and a compiler generates code for any language.
Think of a .proto file like a blueprint for a socket. You draw the shape of the plug and the shape of the outlet. The generator builds the plug for Rust and the outlet for Python. If you try to shove a square plug into a round hole, the compiler stops you before you even plug it in. tonic is the Rust implementation of this system. It handles the HTTP/2 plumbing, the serialization, and gives you async functions that return Result types. The community treats tonic as the default gRPC crate. Older crates like grpc-rs exist but have largely faded; tonic is the active standard.
Setting up the project
You need three pieces: the tonic runtime, the tonic-build code generator, and a .proto file. The generator runs at compile time, so it lives in build-dependencies.
# Cargo.toml
[package]
name = "my-service"
version = "0.1.0"
[dependencies]
# Runtime for server and client
tonic = "0.12"
# Async runtime
tokio = { version = "1", features = ["full"] }
[build-dependencies]
# Code generator runs during `cargo build`
tonic-build = "0.12"
Create a build.rs in the project root. This script executes before your main code compiles. It reads the .proto file and emits Rust source code into the build output directory.
// build.rs
fn main() {
// Generate Rust types and traits from .proto files.
// This must run before the compiler sees main.rs.
tonic_build::compile_protos("proto/hello.proto").unwrap();
}
Define your service in proto/hello.proto. This is the single source of truth for the interface.
// proto/hello.proto
syntax = "proto3";
package hello;
service HelloService {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
Implementing the server
The generated code provides a trait named after your service. You implement that trait to define the behavior. tonic wraps requests and responses in generic types to carry metadata and headers. You must extract the inner data using .into_inner().
// src/main.rs
use tonic::transport::Server;
use tonic::Request;
// Generated module from build.rs
mod hello {
// Include the generated code from the build output directory.
// OUT_DIR is set by cargo during the build.
include!(concat!(env!("OUT_DIR"), "/hello.rs"));
}
use hello::hello_service_server::{HelloService, HelloServiceServer};
use hello::{HelloRequest, HelloReply};
#[derive(Default)]
struct MyHelloService;
// Implement the trait generated from the proto file.
// The compiler ensures your signature matches the contract exactly.
#[tonic::async_trait]
impl HelloService for MyHelloService {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> Result<tonic::Response<HelloReply>, tonic::Status> {
// Extract the payload from the Request wrapper.
// into_inner() consumes the request, enforcing single-use semantics.
let name = request.into_inner().name;
Ok(tonic::Response::new(HelloReply {
message: format!("Hello {}!", name),
}))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:50051".parse()?;
let service = MyHelloService::default();
// Start the server, binding the generated service to an address.
// tonic handles HTTP/2 framing and routing automatically.
Server::builder()
.add_service(HelloServiceServer::new(service))
.serve(addr)
.await?;
Ok(())
}
The #[tonic::async_trait] macro is mandatory. Rust traits cannot directly contain async fn methods in a way that supports object safety. The macro transforms the method to return a boxed future, allowing tonic to call it dynamically. Without the macro, you'll hit E0728 or object-safety errors. Trust the macro. It's the bridge between the trait definition and the async runtime.
How the flow works
When you run cargo build, the build script executes first. tonic-build parses hello.proto, generates Rust structs for HelloRequest and HelloReply, and generates the HelloService trait. It writes this code to a file in OUT_DIR. Your main.rs includes that file. The compiler now sees HelloService as a concrete trait. You implement it. The tonic runtime registers your implementation with the server builder. When a client connects, tonic deserializes the HTTP/2 frame into a HelloRequest, routes it to your say_hello method, and serializes your HelloReply back to the wire. If the client sends malformed data, tonic returns a status error before your code runs. You never see the raw bytes.
Realistic client and error handling
Real services fail. Clients need to handle errors, and servers need to return meaningful status codes. tonic uses tonic::Status for errors, which maps to gRPC status codes like OK, INVALID_ARGUMENT, or INTERNAL.
// src/client.rs
use hello::hello_service_client::HelloServiceClient;
use hello::{HelloRequest, HelloReply};
use tonic::Request;
pub async fn call_server() -> Result<HelloReply, Box<dyn std::error::Error>> {
// Connect to the server.
// The URL must start with http://, even for plaintext gRPC.
// tonic uses the scheme to determine transport behavior.
let mut client = HelloServiceClient::connect("http://[::1]:50051").await?;
let request = Request::new(HelloRequest {
name: "Rustacean".to_string(),
});
// Call the RPC. The client handles serialization and HTTP/2 framing.
let response = client.say_hello(request).await?;
Ok(response.into_inner())
}
Servers should return specific status codes. Don't return generic errors. Use tonic::Status constructors to communicate the nature of the failure.
// Inside server impl
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> Result<tonic::Response<HelloReply>, tonic::Status> {
let name = request.into_inner().name;
if name.is_empty() {
// Return a specific gRPC status code.
// Clients can match on code to handle validation errors differently.
return Err(tonic::Status::invalid_argument("Name cannot be empty"));
}
Ok(tonic::Response::new(HelloReply {
message: format!("Hello {}!", name),
}))
}
Convention aside: always use http:// in the client URL, even for plaintext connections. tonic requires the scheme. If you pass grpc:// or just the IP address, the connection fails with a parse error. This trips up beginners who expect a custom scheme. The http:// prefix tells tonic to use the HTTP/2 transport layer.
Pitfalls and compiler errors
Returning the wrong type. The trait signature is fixed. You must return Result<tonic::Response<T>, tonic::Status>. If you return Result<T, String>, the compiler rejects you with E0308 (mismatched types). It expects Response and Status. Wrap your data in Response::new and convert errors to Status.
Accessing fields on Request. You receive Request<T>, not T. If you try to access .name directly on the request, you get E0609 (no field name on struct Request). Call .into_inner() to get the payload, or .get_ref() if you need to inspect metadata first. The wrapper exists to carry headers and context. Respect the boundary.
Missing Send bounds. tonic server handlers must be Send. If you capture a non-Send value in your service struct, you'll hit E0277 (trait bound not satisfied). This often happens with Rc<T> or raw pointers. Use Arc<T> for shared state in async contexts. The runtime moves tasks across threads; your data must be safe to move.
Build script errors. If tonic-build fails, the build stops. Check that the .proto path is correct relative to the project root. The build script runs in the project root, not the src directory. Path errors are common. Verify the path in compile_protos.
Check the return type. The trait signature is law. If the compiler complains about types, look at the trait definition in the generated code. It will show you exactly what's expected.
When to use tonic
Use tonic when you need high-performance, type-safe RPC between services and want the standard Rust gRPC implementation. Use tonic when you are integrating with existing gRPC services written in Go, Python, or Java; the .proto contract guarantees compatibility. Use axum or warp with JSON endpoints when your clients are browsers or mobile apps that cannot speak gRPC directly; gRPC-Web exists but adds complexity. Use serde_json and hyper when you are building a simple prototype and don't want the overhead of protobuf compilation; switch to tonic when the schema grows or performance matters. Use prost directly when you only need serialization and don't care about the RPC layer; tonic includes prost under the hood.
Treat the .proto file as the single source of truth. If the contract changes, regenerate the code. Don't hand-edit generated files. The build script is your friend. Let it handle the boilerplate so you can focus on the logic.