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 only when you need dyn.

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-trait crate 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 .