# tarpc: a Rust RPC framework that defines its schema in code

> tarpc is a Rust RPC framework from Google that trades .proto files for Rust traits. This article covers how the transport and deadline model work, how to get a first service running, and where the approach stops being the right call.

**google/tarpc** — An RPC framework for Rust with a focus on ease of use.

- Repository: https://github.com/google/tarpc
- Stars: 3,743 · Forks: 231
- Language: Rust
- License: MIT
- Published: 2026-09-23 · Updated: 2026-09-23 · Language: en
- Canonical page: https://hysenlabs.com/projects/google-tarpc

## The schema-in-code bet that separates tarpc from gRPC

Most RPC frameworks ask you to write the interface in an IDL: a .proto file, a Cap'n Proto schema, something that lives outside your source tree. tarpc inverts that. The README states the project "differentiates itself from other RPC frameworks by defining the schema in code, rather than in a separate language such as .proto," and the consequence is that there is no separate compilation process and no context switching between languages. In practice that means an interface change is a Rust diff rather than a .proto diff plus a regenerated stub, and rust-analyzer sees the generated trait like any other trait.

The audience follows from that bet. tarpc is for Rust teams building internal services where both the caller and the callee are Rust, and where the interface is owned by the same people who implement it. It is a poor fit for a public API surface that non-Rust consumers must call, because the schema has no independent existence they can read. The README also carries a disclaimer that this is not an official Google product, which matters if you were assuming the same support posture as a Google Cloud SDK.

## Transports, channels and the Stream plus Sink contract

The mechanism is narrower than it first appears. Any type implementing `Stream<Item = Request> + Sink<Response>` can act as a transport, per the README's list of features. That single bound is why the framework can run over an in-process channel in the example and over TCP in the `example-service` directory without the service trait changing. It also explains the claim that `Send + 'static` is optional: if the transport does not require those bounds, neither does tarpc.

The README ships a generic `serde_transport` behind the `serde-transport` feature, with TCP support behind the `tcp` feature. Serialization itself is opt-in through the `serde1` Cargo feature, which makes requests and responses `Serialize + Deserialize`. That is a deliberate split. An in-memory transport pays nothing for serialization, while a network transport pays for it explicitly. If you are moving an existing in-process service to a socket, that feature flag is the line where the cost appears.

The server side is assembled from `server::BaseChannel::with_defaults`, which wraps a transport and produces something you call `execute` on with the service. The example spawns a task per response, so requests are handled concurrently rather than serially. Cancellation is structural rather than bolted on: dropping a request sends a cancellation message, the server stops unfinished work, and that cancellation cascades through the server's own downstream requests.

## Installing tarpc and running the hello service

The README gives the dependency line directly. Add tarpc to `Cargo.toml`:

```toml
tarpc = "0.37"
```

The example that follows uses tokio, so it lists a fuller dependency set. Note that the tokio integration is itself a feature flag:

```toml
anyhow = "1.0"
futures = "0.3"
tarpc = { version = "0.37", features = ["tokio1"] }
tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] }
```

The service definition is an attribute macro on a trait. The README says the `tarpc::service` attribute "expands to a collection of items that form an rpc service," and that generated trait is what you implement:

```rust
#[tarpc::service]
trait World {
    /// Returns a greeting for name.
    async fn hello(name: String) -> String;
}
```

Implementing it is ordinary Rust. The generated trait method takes a `context::Context` as its first argument, ahead of the declared parameters:

```rust
#[derive(Clone)]
struct HelloServer;

impl World for HelloServer {
    async fn hello(self, _: context::Context, name: String) -> String {
        format!("Hello, {name}!")
    }
}
```

Wiring client and server over an in-process channel is the last step. The attribute generates `WorldClient`, whose `new` takes a config and any transport:

```rust
let (client_transport, server_transport) = tarpc::transport::channel::unbounded();

let server = server::BaseChannel::with_defaults(server_transport);
tokio::spawn(
    server.execute(HelloServer.serve())
        .for_each(|response| async move {
            tokio::spawn(response);
        }));

let mut client = WorldClient::new(client::Config::default(), client_transport).spawn();
let hello = client.hello(context::current(), "Stim".to_string()).await?;
println!("{hello}");
```

Running that prints `Hello, Stim!`. The README notes this is an in-process channel and that real code will likely talk over the network, pointing at `example-service` for the fuller version. For generated documentation of everything the macro expands, the README says to use `cargo doc` as usual.

## Deadlines propagate, which changes how you write the server

Deadline handling is the part of tarpc that most affects application code. Requests default to a 10 second deadline when unspecified, and the server stops work automatically once that deadline passes. The propagation rule is the interesting half: requests the server sends that use the request context inherit the remaining budget. The README's own arithmetic is that a server handling a 10 second request, doing 2 seconds of work, then calling another server leaves that second server an 8 second deadline.

That is a real design commitment. It means a slow hop early in a call chain does not get silently re-budgeted at each layer, and it means you should pass `context::current()` down rather than calling downstream services with a fresh context. If you write a server that ignores the incoming context when making its own calls, you have opted out of the guarantee, and the framework will not stop you. The same context carries trace information, so the two concerns travel together whether or not you want them to.

## Tracing is built in, but you supply the subscriber

tarpc is instrumented with `tracing` primitives extended with OpenTelemetry traces. The README says a compatible subscriber such as Jaeger lets you follow one RPC through client, server and downstream dependencies. It also notes that applications with no distributed tracing collector can still feed the instrumentation to an ordinary logger like env_logger.

This is a sensible default and a limited one. The framework emits spans; it does not choose where they go. If you already run a tracing subscriber, tarpc slots in. If you do not, the instrumentation is inert until you configure one, and the README does not walk through that configuration. Treat tracing support as a reason the framework will not fight your observability stack, not as a reason it comes with observability out of the box.

## Where tarpc is the wrong tool

The schema-in-code choice has a cost that the README does not dwell on. An interface defined only as a Rust trait is not consumable by a Python, Go or Java client without someone re-declaring it by hand. gRPC and Cap'n Proto exist partly to solve exactly that, and for cross-language service boundaries they remain the right answer. If your organization has a schema registry, an API review process that reads .proto files, or client teams outside Rust, tarpc removes the artifact those processes depend on.

The transport bound is the second constraint. A transport must implement `Stream<Item = Request> + Sink<Response>`. If the thing you need to talk to speaks HTTP/2 with gRPC framing, or JSON-RPC over a web socket with its own envelope, you are writing an adapter before you write any service logic. The README mentions a `serde_transport` with TCP support but does not document an HTTP transport, and it does not document rollback behavior for a failed upgrade. The repository has no released versions recorded, so the version string in `Cargo.toml` is the practical anchor.

A third case: if your services are synchronous and short-lived, pulling in an async runtime and a futures-based transport layer is more machinery than a direct function call over a channel needs.

## Jsonrpsee and gRPC approach the same problem differently

Jsonrpsee is a Rust JSON-RPC library, and the difference is in the wire format and the schema. JSON-RPC is a text protocol with a fixed method-call envelope; tarpc generates a typed Rust trait and, when the `serde1` feature is on, serializes requests and responses through Serde. Jsonrpsee's advantage is that a JSON-RPC endpoint is callable from a browser, a shell script or any language with an HTTP client, which is the same advantage gRPC gets from its IDL and its broad language support.

tarpc gives up that reach in exchange for typed method signatures, no separate schema file, and a transport abstraction you can satisfy with an in-memory channel. The honest framing is that tarpc optimizes the Rust-to-Rust case and accepts that everything else is harder. If your first requirement is a client in another language, the other two frameworks answer a question tarpc deliberately does not.

## Licence, workspace layout and upgrade surface

tarpc is MIT licensed, per the badge and LICENSE file in the repository root. MIT is permissive: it allows use in closed-source products provided the copyright notice and permission notice are retained. That is a description of the licence text, not legal advice, and anyone shipping a product should have their own counsel read it.

The repository is a Cargo workspace with three members: `example-service`, `tarpc` and `plugins`. The `plugins` crate is where the `#[tarpc::service]` macro lives, so a change to the macro and a change to the runtime can move independently. The workspace also sets `split-debuginfo = "unpacked"` for the dev profile, a macOS-oriented setting that affects debug builds only. The last push to the repository was on 2026-08-12.

Upgrade cost is mostly the attribute macro's output. Because the schema is code, a change in generated types shows up as compile errors in your service implementations, which is a fast feedback loop but also means a minor version bump can require edits across every service crate. The README does not document a migration guide, and the repository has a RELEASES.md at the top level that is the place to check before bumping.

## Conclusion

Adopt tarpc when both ends of the wire are Rust and you want the service definition to live in the same crate as the implementation, with no codegen step outside cargo. Do not adopt it when the peer is a non-Rust service that already speaks gRPC or Cap'n Proto, or when you need a separate schema artifact that other teams can consume. Before committing, verify that the transport you plan to use satisfies the Stream<Item = Request> + Sink<Response> bound, and read the tarpc crate's own docs on docs.rs for the generated client and server types, since the README stops at the in-process example.

## FAQ

### How does RPC actually work?

RPC stands for Remote Procedure Call, a function call where the work of producing the return value is done somewhere else. When an RPC function is invoked, the function contacts another process and asks it to evaluate the function instead, then returns the value that process produced.

### What does the acronym gRPC stand for?

The README does not expand the acronym. It names gRPC and Cap'n Proto as two well-known RPC frameworks and uses them as the comparison point for tarpc's decision to define schemas in code rather than in a separate language such as .proto.

### Is RPC still used?

The README states that RPC frameworks are a fundamental building block of most microservices-oriented architectures, and it lists gRPC and Cap'n Proto as well-known examples. tarpc itself is a Rust RPC framework in that same category.

## Sources

- [google/tarpc on GitHub](https://github.com/google/tarpc)
- [Issues](https://github.com/google/tarpc/issues)
- [License: MIT](https://github.com/google/tarpc/blob/main/LICENSE)
- [README](https://github.com/google/tarpc/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/google-tarpc
