Why your async trait still can’t go behind dyn, and what Rust is doing about it
By Tarun Nahak
Almost every async Rust backend grows a trait like this one sooner or later:
trait Storage {
async fn get(&self, key: &str) -> Option<Bytes>;
async fn put(&self, key: &str, value: Bytes) -> Result<(), StorageError>;
}
It compiles fine. async fn in traits has been stable since Rust 1.75, at the end of 2023. Then you try to pick the backend at startup and keep it in your app state as Arc<dyn Storage>, and the compiler stops you: the trait is not dyn compatible, because method get is async.
The 1.75 stabilization deliberately left dyn out, and nearly three years later it’s still the first wall people hit with async traits. Here’s why it happens, what to use today, and what the Rust project plans to do about it.
What an async fn in a trait returns
An async fn is sugar for a function that returns impl Future. The 1.75 announcement spells it out: an async fn in a trait desugars to a method returning impl Future<Output = ...>. So get is really this:
fn get(&self, key: &str) -> impl Future<Output = Option<Bytes>>;
That impl Future is a different anonymous type in every implementation. The compiler turns each async body into a state machine that stores everything it holds across an .await. An S3-backed store holds a request in flight, a response buffer and maybe some retry state. An in-memory store holds a reference and a key. Same method, two types, two very different sizes.
Why dyn needs to know the size
When you call a method through &dyn Storage, the call goes through a vtable and the caller doesn’t know which implementation is behind it. That’s fine for a method that returns Option<Bytes>, because the return type is the same for every implementation. It’s not fine for a method that returns an opaque future. The Rust project’s goal for this feature puts it in one line: “The caller must provide storage for the returned value, but doesn’t know its size.”
The standard way out is to give the future a known size by putting it on the heap. A Pin<Box<dyn Future>> is always the same size, a data pointer and a vtable pointer, whatever future sits inside it, so it fits through a vtable. The cost is one allocation per call.
What I use today
Static dispatch first. Most of the time dyn isn’t actually needed. A generic S: Storage parameter, or impl Storage, keeps every call unboxed. When the set of backends is closed and known, an enum works too:
enum AnyStorage {
S3(S3Store),
Memory(MemStore),
}
impl Storage for AnyStorage {
async fn get(&self, key: &str) -> Option<Bytes> {
match self {
AnyStorage::S3(s) => s.get(key).await,
AnyStorage::Memory(m) => m.get(key).await,
}
}
// put follows the same pattern
}
No boxing, no macro, and adding a backend becomes a compile error at every match that needs updating.
When I really need dyn: async-trait or dynosaur. The async-trait crate rewrites each async method to return Pin<Box<dyn Future + Send + 'async_trait>> and wraps the body in Box::pin(async move { ... }). It’s simple and it’s everywhere, but it boxes every call, static ones included, and its futures are Send unless you write #[async_trait(?Send)].
dynosaur, maintained by Santiago Pastorino and Tyler Mandry, takes the other approach. You keep native async fn in the trait and ask for a dyn-friendly companion type:
#[dynosaur::dynosaur(DynStorage = dyn(box) Storage)]
trait Storage {
async fn get(&self, key: &str) -> Option<Bytes>;
}
Anywhere you would have written dyn Storage, you write DynStorage instead, and you build one with constructors such as DynStorage::new_box(store). Calls through DynStorage box the returned future. Static calls on the concrete type don’t.
Or by hand. Declare the method as fn get(&self, key: &str) -> Pin<Box<dyn Future<Output = Option<Bytes>> + Send + '_>>. It works, and you lose the ergonomics of async fn in every implementation.
The Send problem next to it
A second gap shows up as soon as you tokio::spawn a call. With native async fn in a trait, a generic caller can’t require the returned future to be Send, so this fails with “future cannot be sent between threads safely”:
async fn warm_cache<S: Storage + Send + Sync + 'static>(store: Arc<S>) {
tokio::spawn(async move {
store.get("config").await; // the future from get() might not be Send
});
}
Today’s answer, shipped alongside 1.75, is the trait-variant crate. Put #[trait_variant::make(Storage: Send)] on a LocalStorage trait and it generates a second trait, Storage, whose futures are all Send. The longer-term fix is return type notation, which lets the caller write the bound directly, as in S::get(..): Send. RTN is implemented on nightly behind the return_type_notation feature, and its 2026 project goal says full stabilization is blocked on the next-generation trait solver and is intended for late this year.
The plan for 2026
The Rust project’s 2026 goals include “Native async fn dynamic dispatch in traits”. It’s accepted, spans 2026 and 2027, and is owned by Jack Huey and Santiago Pastorino. It comes in two phases:
- Method-scoped dyn compatibility, about four months. Instead of rejecting a whole trait because some methods can’t be dispatched dynamically, the trait becomes dyn compatible anyway, and those methods are reachable only through a new mechanism.
- Async through dynamic dispatch, about eight months, built on top of that.
The first concrete piece is a deliberately minimal, nightly-only helper that boxes at the call site:
async fn fetch_data(client: &dyn HttpClient, url: &str) -> Response {
std::preview::dyn_box!(client.fetch(url)).await
}
The allocation happens only where you ask for it, and static calls don’t change at all. The roadmap calls this .box notation, but it’s explicit that no final syntax has been agreed and that the details will be settled in RFCs. Treat any syntax you see now as a sketch.
What I’d do today
- Reach for generics or an enum first. A lot of code that seems to need dyn has a closed set of implementations.
- For open-ended dyn, use
dynosaurin new code so static calls stay unboxed, andasync-traitwhere a codebase already standardizes on it. - Don’t fear the box. One allocation per call is noise next to a network round trip. It matters in tight loops, not in a storage call.
- Watch the project goal. When native support lands, traits already written with plain
async fnwill have the least to change.