Async fn in Traits: What Actually Works in Rust Now (and What Still Doesn’t)
The short answer
If you’re using static dispatch, async fn in traits works in stable Rust without any macros. If you need dyn Trait, you’re not there yet — async methods still break object safety, and no amount of rustup update will fix that today.
How we got here
For years, the only practical way to write async methods in a trait was the async_trait crate by David Tolnay. It worked via a proc macro that rewrote every async method to return a boxed future — Pin<Box<dyn Future<Output = T> + Send>>. It got the job done, but the heap allocation on every call added up in hot paths, and the error messages it produced were famously hard to read.
Rust 1.75 (December 2023) was the first crack in the dam. The team stabilized async fn in traits and return-position impl Trait (RPITIT), which meant you could write trait methods that return unboxed, statically-dispatched futures. The catch was an explicit list of limitations — object safety and auto-trait inference being the big ones.
Rust 1.85 (February 2025) shipped async closures alongside the Rust 2024 edition — the largest edition the language has ever released. Async closures let you write closures that return futures and capture values from their environment. They come with three matching traits: AsyncFn, AsyncFnMut, and AsyncFnOnce, mirroring the sync closure hierarchy.
What actually works today
Static dispatch is solid. This compiles on stable Rust with no macros:
trait Fetcher {
async fn fetch(&self, url: &str) -> String;
}
struct HttpFetcher;
impl Fetcher for HttpFetcher {
async fn fetch(&self, url: &str) -> String {
format!("fetched: {}", url)
}
}
async fn run<F: Fetcher>(f: F) {
let result = f.fetch("https://example.com").await;
println!("{}", result);
}
Return-position impl Trait in traits also works, giving you opaque future types without boxing. Axum 0.8, released January 2025, dropped its async_trait dependency specifically because this feature matured — custom extractors implementing FromRequest no longer need the macro wrapper. That release also switched route syntax from /:param to /{param}, aligning with OpenAPI conventions.
What still doesn’t work
Dynamic dispatch
This is where most people hit a wall. Traits with async methods aren’t object-safe, so dyn Fetcher won’t compile. The reason is fundamental: an async method’s return type differs per implementation, and a vtable can’t hold a function pointer whose return type varies. There’s active work in the async working group on this, but nothing is stable yet.
Send and Sync bounds
When you need the futures from a trait method to be Send — common with Tokio — the compiler doesn’t automatically infer it. You’ll need to add bounds manually, and it gets verbose fast. The async_trait crate handled this cleanly via an attribute flag (#[async_trait(?Send)] to opt out). Native async traits don’t have an equivalent shorthand yet.
Lifetime complexity
Async trait methods that borrow from &self can require explicit lifetime annotations more often than their sync counterparts. The compiler errors have improved since 1.75, but you’ll still hit cases where the borrow checker asks for something that feels unnecessary.
Workarounds for dyn Trait
- Enum dispatch. If your implementor set is closed, wrap them in an enum and match on it. No heap allocation, object safety sidestepped entirely.
- Manual boxing. Return
Pin<Box<dyn Future<Output = T> + Send + 'static>>explicitly in your trait definition. Verbose, but the type is unambiguous and you stay macro-free. - Keep async_trait for dynamic dispatch only. Nothing stops you from using native
async fnin traits for static-dispatch paths and reaching for the crate only where you genuinely needdyn Trait. A lot of production codebases do exactly this right now.
The async_trait crate isn’t going anywhere — it’s a compatibility shim that will keep working. Mixing native and macro-based async traits in the same project is a reasonable, unsurprising choice in 2026, not a code smell.
