Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Async Trait Problem

For roughly six years, you could not write async fn in a trait. You could write a trait. You could write async fn outside a trait. You could not put one inside the other. The reason is genuinely subtle, the workaround was a third-party macro, and the eventual fix landed in stable in November 2023 — and is still, as of 2026, slightly worse than the workaround in non-trivial cases.

This is the messiest corner of async Rust. It is also the corner the language designers care most about getting right, because it is the principal blocker on async Rust feeling like a coherent language feature instead of a bolted-on extension.

Why async fn in traits was hard

Recall that async fn foo(...) -> T desugars to fn foo(...) -> impl Future<Output = T>. The return type is an opaque type — a specific type that the compiler picks, that the caller doesn’t know but does know implements Future. Each async fn produces a different opaque type, even if the source code looks identical.

This is fine for free functions. The compiler picks a type, the caller treats it as impl Future, life goes on.

It is not fine for trait methods. A trait is a contract: every implementor of the trait must provide a method with a compatible signature. If async fn process(&self) -> Output desugars to fn process(&self) -> impl Future<Output = Output>, what is the compatible signature? Different implementors have different state machine types. The trait can’t say “returns some type implementing Future” without somehow letting each implementor pick its own.

The mechanism that does this is “return-position impl Trait in traits” (RPITIT, pronounced “ripit”). Every implementor’s method gets to pick its own concrete return type, and the trait says only impl Future. The trait’s associated type machinery tracks the implementor’s choice.

This is the right answer. It took a while to land because it required:

  1. Figuring out the type theory: how does an opaque type in a trait method interact with subtyping, with object safety, with Send bounds, with auto-traits?
  2. Figuring out lifetimes: the future returned by an async fn borrows from self (and other arguments). How is that lifetime expressed in the trait?
  3. Figuring out object safety: can you have dyn Trait for a trait with async fn? (Spoiler: not exactly. We’ll get there.)
  4. Figuring out Send bounds: by default, the future’s Send-ness is inferred. How does the trait let the caller require Send?

Each of these took years and several RFCs to resolve. The result is that async fn in traits works in most common cases as of stable Rust 1.75 (December 2023), but the rough edges around dyn Trait and conditional Send bounds are still being smoothed.

The async-trait macro era

Before native support, the standard answer was the async-trait crate. It provided an attribute macro:

#![allow(unused)]
fn main() {
#[async_trait]
trait Repository {
    async fn get(&self, id: Id) -> Result<Item, Error>;
    async fn put(&self, item: Item) -> Result<(), Error>;
}
}

The macro rewrote each async fn into a fn returning Pin<Box<dyn Future<Output = ...> + Send + 'async_trait>>. So:

#![allow(unused)]
fn main() {
trait Repository {
    fn get<'a>(&'a self, id: Id) -> Pin<Box<dyn Future<Output = Result<Item, Error>> + Send + 'a>>;
    fn put<'a>(&'a self, item: Item) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>>;
}
}

This worked. It always worked. The cost: every method call allocates a Box. The boxed future is dynamically dispatched (it’s a dyn Future). The + Send is hard-coded (you could opt out with #[async_trait(?Send)], but only at the trait level, not per-method or per-implementor).

For most use cases, the cost was acceptable. A heap allocation per method call is fine for most application code. For high-throughput async code, it was a real cost; benchmarks showed async-trait adding meaningful overhead in tight loops. But the macro got the language unblocked, and most production async Rust shipped with async-trait in the dependency tree.

Native async fn in traits, the good case

Stable Rust 1.75 made this work:

#![allow(unused)]
fn main() {
trait Repository {
    async fn get(&self, id: Id) -> Result<Item, Error>;
    async fn put(&self, item: Item) -> Result<(), Error>;
}

struct Postgres { /* ... */ }

impl Repository for Postgres {
    async fn get(&self, id: Id) -> Result<Item, Error> { /* ... */ }
    async fn put(&self, item: Item) -> Result<(), Error> { /* ... */ }
}
}

No macro. No Box. Each call returns the implementor’s specific opaque future type. Static dispatch. Zero overhead.

For application code calling repo.get(id).await, this is essentially free. It is what async-trait should have been from the start, and it is what the language now provides natively.

Native async fn in traits, the rough case

Two cases are still rough.

Case 1: dyn Trait. You cannot say:

#![allow(unused)]
fn main() {
let r: Box<dyn Repository> = Box::new(Postgres::new());
}

The trait is not object-safe. Object safety requires that all method return types have a known size, but async fn returns an opaque type whose size depends on the implementor. There is no single dyn Repository because each implementor has a different future type.

The workaround in stable Rust as of early 2026 is to either:

  • Use async-trait for the boxed-trait-object case, accepting the boxing cost.
  • Define a parallel boxed-future trait yourself, and provide a blanket impl converting between them. This is what the proposed “trait transformers” RFC will eventually subsume; today you write it by hand.
  • Use the trait-variant crate (sponsored by the Rust async working group) to generate the boxed variant of a trait from the native one.

The native + dyn-compatible story is in active development. The current thinking (per the async vision document and recent posts from the async WG) is that there will eventually be a dyn AsyncTrait form that handles the boxing for you, but it is not yet stable.

Case 2: Send bounds on the returned future. When you write:

#![allow(unused)]
fn main() {
trait Repository {
    async fn get(&self, id: Id) -> Result<Item, Error>;
}
}

The future returned by get is whatever the implementor produces. If the implementor’s future happens to be Send, great. If not, also great — both are valid implementations. But what if the caller needs the future to be Send, because they’re going to tokio::spawn it?

You need a way to write the trait such that callers can require Send-ness from any implementor. The current syntax is the Send bounds on associated return types feature, often written:

#![allow(unused)]
fn main() {
trait Repository: Send + Sync {
    fn get(&self, id: Id) -> impl Future<Output = Result<Item, Error>> + Send;
}
}

That is, you spell out the desugaring and add + Send to the return type. This works but loses the async fn syntax. As of 2026 there is a more ergonomic syntax in nursery RFC discussions — something like async fn get(&self, id: Id) -> Result<Item, Error> + Send — but it has not stabilized.

The pragmatic recommendation: if all your implementors will produce Send futures (which is true for most repository-style traits where the implementations are straightforward database calls), write the desugared form with + Send. If you need to support both Send and !Send implementors, you have a harder problem and may want to define two traits.

Return-position impl Trait in traits, more generally

async fn in traits is a special case of a more general feature: return-position impl Trait in traits, or RPITIT.

#![allow(unused)]
fn main() {
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn windows(self, n: usize) -> impl Iterator<Item = Vec<Self::Item>>;
    //                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RPITIT
}
}

Without RPITIT, you would have to either:

  • Make windows return a concrete type, defeating abstraction.
  • Add an associated type type Windows: Iterator<...> to the trait, requiring every implementor to declare it.

With RPITIT, the trait says “returns some iterator,” and each implementor’s specific type is hidden but usable.

async fn in traits is just RPITIT for the case where the returned impl Trait is impl Future. Stabilizing one stabilized the other; they are the same machinery.

The general case has the same caveats: it doesn’t work in object-safe traits (because the return type’s size is implementor-dependent), and you need to spell out auto-trait bounds explicitly. But for the cases that work, it’s powerful and clean.

What you should actually do, today

A pragmatic decision tree for writing an async trait in 2026:

Static dispatch only, all implementors known at compile time, no boxing wanted: native async fn in traits. No macro. Add + Send (in the desugared form) to method return types if you need to spawn the futures.

Need dyn Trait for runtime polymorphism, willing to accept allocation: async-trait macro. It is still maintained, still works, still produces correct code. The macro hasn’t been deprecated; it has been complemented.

Building a public library that other crates will consume: prefer native async fn in traits, with explicit + Send bounds where appropriate. Document whether the futures are Send. This gives downstream users the most flexibility.

Need both static dispatch and dyn Trait support from the same source code: look at trait-variant crate, which can generate both forms from one trait definition.

Where this is going

The Rust async working group has, as of early 2026, several proposals at various stages of stabilization that will smooth the remaining edges:

  • dyn* and dynamically-sized return types — a more general mechanism for object-safe traits with opaque returns. Long-tail work, not landing soon.
  • Explicit auto-trait bounds in trait definitions — syntax for letting traits express “this future is Send-conditional on the implementor” cleanly.
  • async drop — let Drop impls be async. This is its own enormous can of worms (what happens if the destructor is dropped without being awaited?) but is on the long-term roadmap.
  • Return-type notation (RTN) — a syntax for naming the return type of a trait method without writing it out, useful for bounds and where-clauses.

None of these will fundamentally change the model. They will reduce the number of cases where you have to know the model in detail. The trajectory is positive, slow, and roughly what you’d expect from a project that prioritizes long-term soundness over short-term ergonomics.

Where this was

For history’s sake, since the controversy has been lived through, it is worth noting that the async-trait situation was, at various points between 2018 and 2023, a real source of frustration in the Rust community. There were extended debates about whether RPITIT was the right approach, whether the lifetime story was workable, whether the boxing in async-trait was acceptable as a permanent answer, and whether the language should have stabilized async without a coherent trait story.

These were good-faith disagreements between thoughtful people. The eventual resolution — RPITIT in stable, with the rough edges getting cleaned up over the following years — is, in retrospect, the right one. The interim cost was that “async Rust” felt incomplete for years. That cost was real, and acknowledging it is honest. The language is in a much better place now than it was in 2021.

Sources