Rust async traits matured. Native async fn in traits stabilized; ecosystem absorbed it. The patterns are now clear, but a few rough edges remain. This post is the working playbook.
Native async fn in traits (AFIT)
trait Repository {
async fn find_by_id(&self, id: u64) -> Result<Option<User>, AppError>;
async fn save(&self, user: &User) -> Result<(), AppError>;
}
impl Repository for PostgresRepo {
async fn find_by_id(&self, id: u64) -> Result<Option<User>, AppError> {
sqlx::query_as!("SELECT ...").fetch_optional(&self.pool).await.map_err(Into::into)
}
async fn save(&self, user: &User) -> Result<(), AppError> { /* ... */ }
}
Stable since Rust 1.75. Most production code uses it.
The Send problem
async fn use_repo<R: Repository>(repo: &R) {
tokio::spawn(async move {
repo.find_by_id(1).await; // ERROR: future not Send
});
}
Native async fn in traits don’t add Send bounds to the returned future. tokio::spawn requires Send.
trait_variant
#[trait_variant::make(SendRepository: Send)]
pub trait Repository {
async fn find_by_id(&self, id: u64) -> Result<Option<User>, AppError>;
}
// Now use SendRepository for spawned contexts
async fn use_repo<R: SendRepository>(repo: &R) {
tokio::spawn(async move {
repo.find_by_id(1).await;
});
}
Generates a Send-bounded variant. Convenient for libraries that should work in spawned contexts.
Dyn dispatch (still tricky)
trait Repository {
async fn find_by_id(&self, id: u64) -> Result<Option<User>, AppError>;
}
let repos: Vec<Box<dyn Repository>> = vec![/* ... */]; // ERROR
Native AFIT isn’t directly dyn-compatible. Workaround:
#[async_trait::async_trait]
pub trait Repository {
async fn find_by_id(&self, id: u64) -> Result<Option<User>, AppError>;
}
let repos: Vec<Box<dyn Repository>> = vec![/* ... */]; // works
The async-trait crate desugars to Pin<Box<dyn Future>>. Heap allocation per call. Use when you need dyn Trait.
For static dispatch (most cases): native AFIT is faster + cleaner.
Generic over async backends
async fn fetch<S>(svc: &S) -> Result<Data, Error>
where S: AsyncFetch + Send + Sync,
{
svc.get().await
}
For libraries: be explicit about Send + Sync if callers spawn.
Returning Futures explicitly
fn process(&self) -> impl Future<Output = Result<(), Error>> + Send + '_ {
async move {
// ...
Ok(())
}
}
When you need full control over bounds. More verbose; equivalent to async fn under the hood.
Trait objects via boxed futures
trait Service {
fn call(&self, req: Request) -> Pin<Box<dyn Future<Output = Response> + Send + '_>>;
}
Pre-AFIT pattern. Still useful when you need a specific bound combination native syntax doesn’t express.
Common patterns
Repository pattern
trait UserRepo: Send + Sync {
async fn find(&self, id: i64) -> Result<Option<User>, RepoError>;
async fn save(&self, user: &User) -> Result<(), RepoError>;
}
pub struct PostgresUserRepo { pool: PgPool }
impl UserRepo for PostgresUserRepo {
async fn find(&self, id: i64) -> Result<Option<User>, RepoError> {
// ... sqlx ...
}
async fn save(&self, user: &User) -> Result<(), RepoError> {
// ... sqlx ...
}
}
Use Arc<dyn UserRepo> if you need DI; static generics for speed/zero-cost.
Tower Service trait
tower::Service is async-trait-like for HTTP services:
impl<B> Service<Request<B>> for MyHandler {
type Response = Response;
type Error = AppError;
type Future = BoxFuture<'static, Result<Response, AppError>>;
fn poll_ready(&mut self, _: &mut Context) -> Poll<Result<(), AppError>> { Poll::Ready(Ok(())) }
fn call(&mut self, req: Request<B>) -> Self::Future { /* ... */ }
}
Pre-AFIT design. Doesn’t use native AFIT. Hyper / Axum / Tower ecosystem still uses this trait directly.
Stream trait
use futures::Stream;
trait EventSource {
fn events(&self) -> impl Stream<Item = Event> + Send + '_;
}
Streams compose well; Stream is its own thing (poll-based, not async fn).
Ecosystem state
- Tokio — full Send compatibility expected. Use trait_variant or explicit bounds.
- smol / async-std — less common in 2026.
- embassy — embedded async; different rules; not Send-bounded.
- axum / tower — uses Service trait pattern.
Common mistakes
1. async-trait everywhere by reflex
Use native AFIT when you can. Box
2. Forgetting Send bounds
async fn handle<R: Repository>(repo: R) { tokio::spawn(...); }
Compile error if R::find isn’t Send. Add the bound, use trait_variant, or restructure.
3. &self references across .await
async fn op(&self) {
let g = self.lock.lock();
something().await; // g held across .await
drop(g);
}
&self is fine; lock guards across .await are not. Drop the guard before await.
4. Static lifetimes everywhere
'static bounds make code easier but limit callers. Use '_ and named lifetimes when you can.
5. Mixing async runtimes
async-std runtime + tokio library = panics. Pick one runtime per app.
What I’d ship today
For new async Rust:
- Native AFIT for traits.
- trait_variant when Send is needed.
- Tokio as the runtime.
async-traitcrate only when dyn Trait is required.- Explicit bounds (
Send,'_) when crossing crate boundaries.
Read this next
If you want my Rust async trait reference (with trait_variant + AFIT), it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .