Rust Async fn in Traits: What’s Stable in 2026 (and What Still Isn’t)

Async fn in traits: what’s actually stable, and what still isn’t

The short version: as of Rust 1.85 (released February 2025 alongside the Rust 2024 Edition), you can write async fn directly inside a trait definition without a proc-macro or a nightly flag. The compiler handles it. For most everyday use cases, that’s enough.

But there are two situations where “stable” doesn’t mean “works the way you’d expect”: dynamic dispatch and Send bounds. Both are worth knowing before you refactor a pile of code and hit the wall mid-sprint.

What static dispatch looks like now

This now just compiles:

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

struct HttpFetcher;

impl Fetcher for HttpFetcher {
    async fn fetch(&self, url: &str) -> String {
        format!("response from {url}")
    }
}

async fn run<F: Fetcher>(f: F) {
    let result = f.fetch("https://example.com").await;
    println!("{result}");
}

The compiler monomorphizes run for each concrete type, so there’s no runtime overhead from boxing futures. Clean, zero-cost, and it covers a large share of real-world use cases.

The dyn Trait wall

Switch to dyn Fetcher and things break. Async fn in traits isn’t object-safe on stable yet, so this fails to compile:

// This does NOT compile
async fn run_dyn(f: &dyn Fetcher) {
    let _ = f.fetch("https://example.com").await;
}

The reason is mechanical. An async fn desugars into a function returning an opaque future type, and each impl gets a different concrete future. A dyn Trait object can’t dispatch to that future without boxing it — the compiler doesn’t know the size at compile time, and it won’t insert the box automatically.

The practical fix is the async-trait crate. It rewrites your async trait methods into Box<dyn Future> under the hood, which restores object safety at the cost of a heap allocation per call. For I/O-bound code, that allocation is almost never the bottleneck. Annotate the trait and every impl:

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

#[async_trait::async_trait]
impl Fetcher for HttpFetcher {
    async fn fetch(&self, url: &str) -> String {
        format!("response from {url}")
    }
}

async fn run_dyn(f: &dyn Fetcher) {
    let _ = f.fetch("https://example.com").await;
}

The dynosaur crate is an experimental alternative that tries to achieve the same with less overhead. Worth watching, but not a production replacement yet.

The Send bound problem

Even with static dispatch, a second problem surfaces when you try to spawn tasks on a multi-threaded runtime. Tokio’s spawn requires the future to be Send. The future returned by an async trait method is an anonymous type, and you can’t write a where clause that constrains it — the desugared associated type has no name to reference.

Return-Type Notation (RTN) is the intended fix. It would let you write F: Fetcher, F::fetch(..): Send. RTN has been available for testing on nightly, but wasn’t stable as of mid-2026.

The workaround: use async_trait, which generates Send-compatible code by default (opt out with ?Send if you genuinely don’t need it). The other option is to avoid dyn Trait at thread boundaries entirely and keep concrete types in spawned tasks.

Async closures, which actually just work

This is the good news section. The AsyncFn, AsyncFnMut, and AsyncFnOnce traits stabilized in Rust 1.85, and they handle the higher-ranked lifetime issues that made the old Fn(&str) -> impl Future bound so painful. If you’ve been boxing futures to pass async callbacks, you can stop:

async fn process_items<F>(items: Vec<&str>, handler: F)
where
    F: AsyncFn(&str),
{
    for item in items {
        handler(item).await;
    }
}

No boxes. No lifetime gymnastics. It compiles and it’s zero-cost.

Where the trait system is going

Rust’s 2026 project goals include a theme called “Unblocking Dormant Traits,” which covers the next-generation trait solver and the features blocked behind it. The Send bound problem and object safety for async traits both sit in that bucket. A stabilizable form of Polonius — the new borrow checker formulation — hit a major milestone on nightly in early 2026, which is a real step forward. None of it lands on stable overnight, but the work is happening.

Quick reference

  • Static dispatch with async fn in traits: works out of the box since Rust 1.85, no crate needed.
  • Dynamic dispatch (dyn Trait): use async_trait, accept the allocation per call.
  • Send bounds in spawned tasks: async_trait again, or avoid dyn at the thread boundary.
  • Async closures: fully stable via AsyncFn* traits, works cleanly without boxing.

Sources

Related Articles

Similar Posts