Async Rust in 2026: Native async fn in Traits, Async Closures, and When You Still Need async-trait

Async fn in traits: what actually works now

Native async fn in traits landed stable with Rust 1.75 in late 2023. For the common case — a generic function over a trait bound — you can drop async-trait entirely. Then Rust 1.85 (February 2025) added async closures, filling the last major ergonomic gap in the story.

Still, there is a real foot-gun: dynamic dispatch. The moment you write dyn MyTrait, the compiler refuses to cooperate with native async methods. That is a fundamental trait-object safety problem, not a missing feature about to land in the next release.

Static dispatch: just works

Here is a trait you can define and use today without any external crate:

trait Fetcher {
    async fn fetch(&self, url: &str) -> String;
}

async fn load<F: Fetcher>(f: &F) -> String {
    f.fetch("https://example.com").await
}

The compiler desugars the async method into an associated impl Future return type. No heap allocation, no macro overhead. It monomorphizes like any other generic function.

Async closures in Rust 1.85

Before 1.85, writing a closure that returned a future and could borrow from its own captures required awkward workarounds — usually wrapping things in an Arc or cloning more than you wanted. The async closure feature, stabilized February 20, 2025, gives you the syntax async |arg| { ... }, implementing the new AsyncFn, AsyncFnMut, and AsyncFnOnce traits — the async counterparts of the standard Fn family.

let process = async |data: &[u8]| {
    tokio::time::sleep(Duration::from_millis(10)).await;
    data.len()
};

let result = process(&[1, 2, 3]).await;

The payoff is clearest in higher-order async functions, where you want to accept an async callback without spelling out Pin<Box<dyn Future>> by hand. No more cloning the world just to satisfy the borrow checker.

Dynamic dispatch: you still need a workaround

If you need a collection of trait objects — a plugin system, a dependency injection container, heterogeneous service dispatch — native async fn makes the trait non-object-safe. Two practical options:

  • The async-trait crate: the #[async_trait] macro rewrites async methods to return Pin<Box<dyn Future + Send + '_>>. One heap allocation per call. Works cleanly with dyn Trait.
  • Manual boxing: declare the return type yourself. Explicit, no macro dependency, but considerably more ceremony.

Axum 0.8.0, released January 2025, removed its internal #[async_trait] usage for handler traits in favor of native impl Future. The release post is worth reading to see exactly how the Tokio team navigated the trade-offs in a real, large codebase.

The Send bound subtlety

Auto-traits do not automatically propagate through native async fn in traits. If you are spawning tasks on a multi-threaded Tokio runtime, those tasks need Send futures — and the compiler will not infer the bound from a trait definition alone.

The trait_variant crate, published by the Rust team, provides a #[trait_variant::make] attribute that generates both a Send and a non-Send variant of a trait from a single definition. It is a reasonable stopgap while the language-level solution is still being designed.

Quick decision guide

  • Generic over a trait bound, static dispatch only: use native async fn, no crate needed
  • Async callbacks and higher-order functions: use the new AsyncFn traits from Rust 1.85+
  • Trait objects, dyn Trait: reach for async-trait or manual boxing
  • Multi-threaded spawn through a trait boundary: look at trait_variant for generated Send variants

The async-trait crate download numbers have not dropped since native async fn in traits stabilized — the dynamic dispatch use case is genuinely common. Native async fn solved one big problem. The dyn problem is a different one.

Sources


Similar Posts