The async trait wall
You are building a plugin system for a game engine. Each plugin needs to load assets from a remote server. You define a trait to standardize the behavior:
trait AssetLoader {
async fn load(&self, path: &str) -> Vec<u8>;
}
You implement it for your HTTP loader. You try to store a list of loaders in a Vec<Box<dyn AssetLoader>> so you can swap them at runtime. The compiler rejects you with E0038 (the trait cannot be made into an object). You switch to a generic function fn process<T: AssetLoader>(loader: &T) and it compiles. You are stuck choosing between performance and flexibility.
This is the async trait problem. Rust's trait system demands concrete return types so the compiler can allocate stack space and generate call sites. async fn returns a future, and the future's type is a unique, anonymous state machine generated by the compiler. Historically, traits could not express "I return some future type that you cannot name." Rust 1.75 stabilized native async fn in traits, solving half the problem. The other half involves dynamic dispatch and object safety.
Opaque return types and the future black box
An async fn is syntactic sugar. The compiler rewrites it to return impl Future<Output = T>. The impl keyword makes the return type opaque. The caller knows the value implements Future and produces a T, but the caller does not know the concrete type. This is intentional. The future type contains the closure captures, the state machine discriminant, and the suspension points. Exposing that type would leak implementation details and break encapsulation.
Traits require the return type to be known to the caller. If a trait method returns String, every implementation must return a String. The caller can allocate a String on the stack and use it. If a trait method returns impl Future, the caller cannot allocate the return value because the size is unknown. The trait defines a contract, but the contract hides the payload.
Think of it like a vending machine. The trait is the interface: you insert coins, you press a button, you get a snack. The trait does not care if the snack is a bag of chips or a candy bar, as long as it fits in the slot. With async fn, the "snack" is a future. The future is the snack. The trait says "you get a future." The problem arises when you want to treat the vending machine as a generic object. If the machine can dispense arbitrarily large snacks, you need a box to hold the snack. That box is the solution for dynamic dispatch.
Native async traits: static dispatch
Rust 1.75 introduced async_fn_in_trait. You can now write async fn directly in trait definitions. This works perfectly for static dispatch. When you call an async trait method through a concrete type, the compiler monomorphizes the call. It sees the concrete implementation, generates the specific future type, and inlines the suspension logic. There is zero overhead.
/// Defines a contract for fetching data asynchronously.
trait Fetcher {
/// Fetches text from a URL.
///
/// The compiler desugars this to return `impl Future<Output = String> + '_`.
/// The `'_` lifetime ties the future to the borrow of `self`.
async fn fetch(&self, url: &str) -> String;
}
struct HttpFetcher;
impl Fetcher for HttpFetcher {
async fn fetch(&self, url: &str) -> String {
// Simulate network delay.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
format!("Response from {}", url)
}
}
async fn run() {
let fetcher = HttpFetcher;
// Static dispatch. The compiler knows `fetcher` is `HttpFetcher`.
// It generates the exact future type and calls it directly.
let result = fetcher.fetch("https://example.com").await;
println!("{}", result);
}
The async fn signature expands to fn fetch(&self, url: &str) -> impl Future<Output = String> + '_. The '_ lifetime is crucial. It means the future borrows self. The future cannot outlive the borrow. If you try to return the future from a function where self is dropped, the compiler rejects you. This prevents dangling references inside the future state machine.
Native async traits are the default choice when you use generics. They provide full performance and type safety. The opaque return type is hidden behind the generic parameter. The caller never needs to know the future type; it just needs to know that T: Fetcher.
Trust the borrow checker on the lifetime. The '_ ensures the future stays alive as long as the data it references.
The dynamic dispatch trap
Static dispatch fails when you need a collection of heterogeneous types. You want a Vec of different fetchers. You reach for Box<dyn Fetcher>. This creates a trait object. The compiler builds a vtable for the trait. The vtable stores function pointers for each method.
The vtable cannot store an opaque return type. The function pointer for fetch must return a concrete type. If the return type is impl Future, the vtable has no place to put the type information. The compiler throws E0038 because the trait is not object-safe. Object safety requires all methods to have concrete return types or return Self by value. impl Future violates this rule.
You cannot put async fn in a trait object without intervention. The trait object needs a concrete return type that works for all implementations. The standard solution is to box the future. The return type becomes Pin<Box<dyn Future<Output = String>>>. This is a pointer. Pointers have a fixed size. The vtable can store a pointer.
Manual boxing is verbose and error-prone. You have to write the return type explicitly, handle Pin, and manage lifetimes. The community convention is to use a macro to automate this transformation.
The async-trait macro solution
The async-trait crate provides a procedural macro that rewrites trait definitions. It changes async fn methods to return Pin<Box<dyn Future>>. It also rewrites the implementations to box the generated future. The macro handles the lifetime elision and the pinning boilerplate.
use async_trait::async_trait;
/// A fetcher trait that supports dynamic dispatch.
///
/// The macro rewrites `async fn` to return `Pin<Box<dyn Future>>`.
/// This makes the trait object-safe.
#[async_trait]
trait Fetcher {
async fn fetch(&self, url: &str) -> String;
}
struct HttpFetcher;
#[async_trait]
impl Fetcher for HttpFetcher {
async fn fetch(&self, url: &str) -> String {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
format!("Response from {}", url)
}
}
struct MockFetcher;
#[async_trait]
impl Fetcher for MockFetcher {
async fn fetch(&self, url: &str) -> String {
// Mock implementation returns immediately.
"Mock data".to_string()
}
}
async fn run() {
// Trait objects work now.
let loaders: Vec<Box<dyn Fetcher>> = vec![
Box::new(HttpFetcher),
Box::new(MockFetcher),
];
for loader in loaders {
// Dynamic dispatch via vtable.
let result = loader.fetch("https://example.com").await;
println!("{}", result);
}
}
The macro adds a heap allocation for every call. The future is boxed before being returned. This allocation happens on the heap, not the stack. For high-throughput systems, this allocation can become a bottleneck. The macro also introduces a small indirection through the dyn Future vtable. The performance cost is usually acceptable for I/O bound workloads where the network latency dwarfs the allocation time. For CPU-bound async loops, the cost matters.
The async-trait macro is a dev-dependency in many projects because it only affects the trait definition and implementation. The callers do not need the macro. However, the trait definition must be visible to callers, so the macro must be in the crate that defines the trait.
Convention aside: keep async-trait imports explicit. Write use async_trait::async_trait; at the top of the file. Do not rely on prelude imports. The macro name is distinct and signals the intent clearly.
Pitfalls and compiler errors
Async traits introduce subtle lifetime and pinning issues. The compiler errors can be cryptic if you do not understand the desugaring.
E0038: the trait cannot be made into an object. This error appears when you try to use dyn Trait with a trait that has async fn but no #[async_trait] macro. The fix is to add the macro or switch to static dispatch.
E0277: trait bound not satisfied. This error appears when the future does not implement Send or Sync as required. Async trait methods often require the future to be Send so it can be moved across thread boundaries. The async-trait macro adds Send bounds automatically if you use the #[async_trait(?Send)] attribute to opt out. If you need Send, ensure all captured variables are Send.
Lifetime elision surprises. Native async traits use lifetime elision. async fn fetch(&self) -> String becomes fn fetch(&self) -> impl Future<Output = String> + '_. The future borrows self. If you try to return the future from a function, the compiler rejects you because self is local to the function. You cannot leak the borrow. The async-trait macro handles this by tying the future lifetime to the reference lifetime explicitly.
Pin safety. Futures must be pinned to prevent self-referential pointers from moving. async fn returns a pinned future implicitly. The async-trait macro returns Pin<Box<dyn Future>>. If you manually box a future, you must use Box::pin. Forgetting pin causes a compile error. The macro saves you from this mistake.
Treat the Pin requirement as a guarantee. The future's memory address is fixed. The runtime relies on this for safety.
Decision matrix
Use native async fn in traits when you only need static dispatch and want zero overhead. This is the best choice for generic functions and library APIs where the caller provides the concrete type.
Use #[async_trait] when you need dyn Trait or trait objects. This is the standard solution for plugin systems, dependency injection, and heterogeneous collections. Accept the heap allocation cost as the price of flexibility.
Use manual Pin<Box<dyn Future>> return types only when you cannot add the async-trait dependency and need object safety. This is rare. The macro is small, stable, and widely used. Manual boxing is error-prone and verbose.
Use async fn in traits when the abstraction requires asynchronous behavior. Do not force synchronous traits to return futures manually. The compiler and macro ecosystem support async traits well.
Reach for async_trait(?Send) when your trait is used in single-threaded runtimes like tokio::task::spawn_local. The default macro adds Send bounds, which break local tasks.