The async trait trap
You are building a plugin system. You have a list of plugins loaded from different files. Some are analytics, some are logging, some are network bridges. You want to store them in a single Vec and call await plugin.initialize() on every item.
You define a trait with an async method. You try to store Vec<Box<dyn Plugin>>. The compiler rejects you. It complains that the trait cannot be made into a trait object. This is the classic async trait trap. You hit a wall where Rust's type system and async futures collide.
The error message points to the async fn in your trait. It tells you trait objects cannot be made for traits with async fn methods. You need to change how the trait returns the future.
Why the compiler fights you
Rust's trait objects rely on a vtable. The vtable is a lookup table generated at compile time. It stores function pointers for every method in the trait. To build this table, the compiler must know the exact size and layout of every return type.
When you write async fn, the compiler rewrites it to return an opaque impl Future. This future is a unique, anonymous struct generated for that specific implementation. The size of this struct depends on every variable captured inside the async block. One implementation might capture a small integer. Another might capture a large buffer. The sizes differ.
The compiler cannot create a vtable for a return type that changes size depending on the implementation. A pointer to a dyn Trait must have a fixed size. If the return type varies, the pointer cannot know how much memory to manage or how to align the data. The vtable breaks.
Think of it like a universal shipping container. The crane can lift any container because they all have the same dimensions. If one container is a cube and another is a long tube, the crane cannot handle them with the same mechanism. You need to wrap every item in a standard box so the crane works.
In Rust, the standard box is Box. By returning a Box<dyn Future>, you hide the varying future size behind a fixed-size pointer. The vtable can now point to a function that returns a pointer. The size is constant. The compiler accepts the trait object.
The manual fix: boxing the future
You can solve this by manually boxing the future. Change the trait method to return a Box containing a trait object for the future. This makes the return type a pointer, which has a known size.
use std::future::Future;
// Return a boxed future trait object.
// The Box is a pointer with fixed size.
trait Drawable {
fn draw(&self) -> Box<dyn Future<Output = ()> + Send>;
}
struct Button;
struct Image;
impl Drawable for Button {
fn draw(&self) -> Box<dyn Future<Output = ()> + Send> {
// Box the async block to erase its unique type.
// The + Send bound allows moving the future across threads.
Box::new(async {
println!("Drawing button");
})
}
}
impl Drawable for Image {
fn draw(&self) -> Box<dyn Future<Output = ()> + Send> {
Box::new(async {
println!("Drawing image");
})
}
}
// Store heterogeneous types in a Vec.
fn render_screen(components: Vec<Box<dyn Drawable>>) {
for component in components {
// Call draw and await the boxed future.
// This requires an async context to run.
let _future = component.draw();
}
}
The Box::new call allocates memory on the heap. It places the unique future struct inside that memory. It returns a pointer to the allocation. The dyn Future<Output = ()> + Send part tells the compiler this pointer refers to any future that returns () and can be sent to other threads.
The + Send bound is almost always required for trait objects. Async runtimes like Tokio move futures between threads to keep all cores busy. If a future captures a reference to stack data, moving it to another thread is unsafe. The Send bound guarantees the future does not hold such references.
Boxing the future adds a heap allocation for every call. It also adds an indirection layer. The runtime must dereference the box to poll the future. This cost is small for most applications. It becomes significant only in tight loops where you call the method millions of times per second.
Measure the allocation cost before you optimize. Premature optimization of async traits often leads to worse code.
Realistic code: native traits and macros
Writing Box<dyn Future<Output = T> + Send> by hand is verbose. It clutters your API. The community developed a macro to hide this boilerplate. The async-trait crate provides a #[async_trait] attribute. You write the trait normally with async fn. The macro rewrites the code to use boxed futures behind the scenes.
use async_trait::async_trait;
// The macro rewrites this to return a boxed future.
// You write clean async syntax. The macro handles the boxing.
#[async_trait]
trait Plugin {
async fn load(&self) -> Result<(), String>;
}
struct DatabasePlugin;
#[async_trait]
impl Plugin for DatabasePlugin {
async fn load(&self) -> Result<(), String> {
// Simulate async work.
// The macro captures this logic in a boxed future.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
Ok(())
}
}
fn run_plugins(plugins: Vec<Box<dyn Plugin>>) {
for plugin in plugins {
// Call the async method via dynamic dispatch.
// The macro-generated code boxes the future automatically.
let _result = plugin.load();
}
}
The macro expands async fn load(&self) into fn load(&self) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>>. It handles the Pin wrapper and the Box. You do not see these types in your source code. The API looks like standard async Rust.
Rust 1.75 stabilized native async traits. You no longer need the macro for most use cases. You can write async fn directly in traits. The compiler generates the boxing code automatically. The syntax is identical to the macro version. The generated code is also identical.
// Native async trait support (Rust 1.75+).
// No macro needed. The compiler handles the boxing.
trait Plugin {
async fn load(&self) -> Result<(), String>;
}
struct DatabasePlugin;
impl Plugin for DatabasePlugin {
async fn load(&self) -> Result<(), String> {
Ok(())
}
}
// Dynamic dispatch works out of the box.
fn run_plugins(plugins: Vec<Box<dyn Plugin>>) {
// ...
}
Native async traits are the standard now. Use them if your Rust version is 1.75 or newer. The async-trait macro is legacy code for new projects. Keep it only if you must support older compilers or need specific configuration options that native traits do not expose yet.
The community convention is clear. Drop the macro dependency. Write native async traits. Update your rust-toolchain file if you are stuck on an old version.
Pitfalls and hidden costs
Dynamic dispatch with async traits introduces subtle issues. The most common is lifetime erasure. When you borrow a value inside an async method, the future captures that borrow. The future must not outlive the borrowed data.
The #[async_trait] macro erases lifetimes to make the boxing work. It rewrites &self to 'static or boxes the borrowed data. This can cause unexpected behavior. If you return a reference from an async method, the macro might box the reference or fail to compile. Native traits preserve lifetimes better. They allow you to write async fn get_data(&self) -> &str with proper lifetime annotations.
Another pitfall is the Send bound. Native async traits and the macro assume Send by default. If you are building a single-threaded runtime or need !Send futures, you must opt out. The macro provides a #[async_trait(?Send)] attribute. Native traits allow you to remove the Send bound from the generated future type. Be explicit about thread safety. If your future holds a Rc or a non-send guard, the default Send bound will reject your code.
Performance is the third pitfall. Every call to a trait method allocates a box. If you have a loop that processes ten thousand items, you allocate ten thousand boxes. This pressure on the allocator can slow down your program. The allocation happens on the heap. The heap is slower than the stack.
You can avoid the allocation by using static dispatch. If you do not need to store different types in a collection, use impl Trait instead of dyn Trait. Static dispatch inlines the code. It eliminates the vtable lookup and the allocation. It is faster.
Use dynamic dispatch only when you truly need polymorphism. If you can pass a generic parameter, do it. The compiler optimizes generics aggressively. Trait objects are a fallback for heterogeneous collections.
Decision matrix
Use native async fn in traits when you are on Rust 1.75 or newer and want clean syntax without external dependencies. The compiler generates the same efficient code as the macro.
Use #[async_trait] when you must support Rust versions older than 1.75 or when you need the ?Send attribute for single-threaded runtimes that native traits do not support yet.
Use manual Box<dyn Future> when you are writing a library that cannot depend on macros or when you need fine-grained control over the future type for interoperability with C or other languages.
Use static dispatch with impl Trait when you do not need to store heterogeneous types in a collection and you want zero overhead. Generics resolve at compile time. No vtable. No allocation.
Use Rc or Arc inside the future when you need to share data across multiple async tasks. The future captures the smart pointer. The reference count keeps the data alive.
Treat the allocation cost as a design signal. If you find yourself boxing futures in a tight loop, rethink the architecture. Batch the work. Use channels. Avoid per-item dynamic dispatch.