Rust async functions in traits: what changed and why
Rust 1.75 made async fn in traits part of stable Rust, removing the need for the async-trait macro in many cases. What you can write now and the limits that remain.
For years, one line of Rust refused to compile: an async fn inside a trait. Traits are how Rust expresses shared behaviour, and async is how it handles network work, so nearly every service ran into the gap. The usual answer was the async-trait crate, a macro that rewrote the function to return a boxed future.
Rust 1.75, released in December 2023, made async fn in traits part of the stable language. You will sometimes see version 1.77 quoted for this. The feature landed in 1.75.
Before and after
With the macro:
use async_trait::async_trait;
#[async_trait]
trait Fetcher {
async fn fetch(&self, url: &str) -> Result<String, FetchError>;
}
With stable Rust today:
trait Fetcher {
async fn fetch(&self, url: &str) -> Result<String, FetchError>;
}
struct HttpFetcher;
impl Fetcher for HttpFetcher {
async fn fetch(&self, url: &str) -> Result<String, FetchError> {
// call the network here
todo!()
}
}
async fn load<F: Fetcher>(fetcher: &F) -> Result<String, FetchError> {
fetcher.fetch("https://example.com/data").await
}
No macro and no extra dependency. There is also no heap allocation per call, because the future is no longer boxed.
Why it took so long
An async fn returns an anonymous future type that the compiler generates. In a trait, each implementation produces a different one. The language first needed a way for a trait method to return "some type that implements this trait". Rust 1.75 stabilized that as well, and async fn in traits is built on top of it.
The limits that remain
No dynamic dispatch. A trait with an async fn cannot be used as dyn Fetcher. If you need a list of different fetchers behind Box<dyn Fetcher>, the async-trait crate is still the practical choice.
Send is not guaranteed. Multi-threaded runtimes such as Tokio need futures that can move between threads. A generic function cannot assume the future returned by a trait method is Send. This is why the compiler warns when a public trait contains an async fn.
For libraries, the Rust project maintains the trait-variant crate, which generates a Send version of a trait from one definition:
#[trait_variant::make(Fetcher: Send)]
trait LocalFetcher {
async fn fetch(&self, url: &str) -> Result<String, FetchError>;
}
What to do in practice
| Situation | Recommendation |
|---|---|
| A trait used inside your own application with generics | Plain async fn in the trait |
| A public library trait | Use trait-variant, or spell out impl Future + Send |
You need dyn Trait |
Keep async-trait for now |
Why it matters
A generic data service is a good example. A Fetcher trait with an async method can be implemented once for HTTP, once for a database and once as a fake for tests. The business logic is written against the trait and never learns which one it is talking to. That pattern is now plain Rust.
The async story has kept moving since then: async closures were stabilized in Rust 1.85. Each step removes a workaround and brings async code closer to the Rust you would write without it.