For years, async traits were Rust's most embarrassing hole. You could write async code, but you couldn't put async functions in traits—not reliably, not safely, not without reaching for nightly features or existential type gymnastics.
That finally changed. Here's the story of what broke, what was built to fix it, and what's still hard.
The Original Problem
Say you wanted to write a trait for a web client:
trait HttpClient {
async fn get(&self, url: &str) -> Result<String, Error>;
}
This looks simple but compiles to nonsense. async fn in a trait desugars to returning a future, and futures are generic. The compiler can't name the future type—this is the "unnameable type" problem.
Early Rust versions simply didn't allow this. You got an error:
error: async fn in trait is unstable
The Workarounds (That We All Used)
1. Return Pin<Box>
Manually box the future:
trait HttpClient {
fn get(&self, url: &str) -> Pin<Box<dyn Future<Output = Result<String, Error>> + '_>>;
}
This works but has problems:
- Extra heap allocation on every call
- Dynamic dispatch kills inlining
- Painful to write manually
2. async-trait Crate
The async-trait crate solved this with a macro:
use async_trait::async_trait;
#[async_trait]
trait HttpClient {
async fn get(&self, url: &str) -> Result<String, Error>;
}
The macro desugars to the boxed version under the hood. It became the de facto standard. Every async Rust project imported it.
But it was still a workaround. A crate shouldn't be necessary for something this fundamental.
3. RPITIT (Return Position Impl Trait in Traits)
Rust 1.75 (December 2023) stabilized RPITIT. This lets you write:
trait HttpClient {
fn get(&self, url: &str) -> impl Future<Output = Result<String, Error>>;
}
At the call site, the return type is inferred. The compiler generates a unique opaque type for each impl. No boxing required—static dispatch when possible, zero overhead when the compiler can monomorphize.
This was huge. But it only worked for sync functions returning futures.
4. async fn in Traits
Rust 1.75 also stabilized async fn directly in traits:
trait HttpClient {
async fn get(&self, url: &str) -> Result<String, Error>;
}
Finally. First-class async in traits, no crates needed.
But there were catches.
What's Actually Stable Now (Early 2026)
As of Rust 1.85+ (early 2026), here's what's stable:
- async fn in traits — works, static dispatch by default
- RPITIT — works for both sync and async fns
- impl Trait in trait bounds —
fn foo() -> impl Clonein a trait - async trait methods in dyn Trait —
dyn AsyncTraitworks
What's still catching people:
- Auto traits don't flow through async traits —
SendandSyncbounds on async trait methods are still tricky. The compiler is conservative here. - Lifetimes in async traits — more complex than sync traits. You'll sometimes need explicit lifetimes.
- Object safety — async traits aren't object-safe.
dyn MyTraitwhereMyTraithas async methods won't work. This is a fundamental limitation.
The Parity Goal
The Rust async team has an explicit goal: "bring async Rust closer to parity with sync Rust." The 2025-2026 roadmap includes:
- Async drop (handling cleanup in async contexts)
- Async iteration (async for loops, streams)
- Better error messages for async trait bounds
- Polonius integration for better borrow checking in async code
These are the remaining pieces. Not blockers anymore, but rough edges.
What This Means For You
If you're starting a new project in 2026:
-
Don't reach for async-trait crate unless you need
dyn MyAsyncTrait. The built-inasync fnin traits is stable and faster. -
Use RPITIT when you need flexibility in return types within traits. It's zero-cost abstraction.
-
Understand the Send/Sync constraints — test your async trait implementations with
tokio::testand assertions about Send bounds. -
Watch for async stream — the final piece of the async iteration puzzle is stabilizing. Once it lands, you'll be able to write
async fn stream() -> impl Stream<Item = T>in traits.
The Bigger Picture
Async traits went from "impossible" to "stable feature" over about 8 years. The journey shows something about Rust's evolution: they don't rush unstable things into std, they get them right.
The rough edges that remain—auto traits, object safety—are hard problems. They're not being ignored. They're being solved carefully.
If you're writing library code today, you no longer need workarounds. The standard library has your back.