When to Use async‑trait in Rust: A Practical Guide
Async‑trait lets you write async methods in traits by boxing the future. This guide explains its purpose, typical use cases, performance trade‑offs, and when to prefer manual Future implementations.
01 Oct 2025, 22:42 UTC

The Problem: Async Functions Inside Traits
Rust’s type system does not allow an async fn to appear directly inside a trait. The compiler rewrites an async function into a hidden impl Future type, which can’t be returned from a trait method because the concrete type would be unknown to callers. Developers usually hand‑craft an associated type Future or use a macro that does the plumbing for them.
async‑trait Crate to the Rescue
The async‑trait crate provides a procedural macro that transforms an async trait method into a Box. This erases the concrete future type while preserving the async signature, letting you write:
use async_trait::async_trait;
#[async_trait]
pub trait DataFetcher {
async fn fetch(&self, id: u64) -> Result;
}
The macro expands to a boxed future, so callers can treat fetch like any other async function without worrying about type parameters.
Typical Use Cases
- Library APIs: Expose async methods in a trait that library users will implement.
- Simplifying code: Avoid boilerplate
type Future = ...and manualasync move { ... }blocks. - Cross‑runtime compatibility: Works with both Tokio and async‑std without changing the trait definition.
Performance Trade‑offs
Because the macro boxes the future, each call allocates on the heap. In a typical request‑handling loop this cost is negligible, but in tight loops or high‑frequency callbacks the allocation overhead becomes visible. The boxed future also loses type‑specific optimizations and may hurt cache locality.
Example measurement (conceptual):
- Call
fetchvia the async‑trait implementation 1,000,000 times in a loop and time the duration. - Implement the same trait manually: return an inline
impl Futurewithout boxing and repeat the measurement. - Compare the elapsed times and the number of heap allocations (e.g., with
valgrind --tool=massiforcargo-bench).
When Manual Futures Win
If you need:
- Zero allocation or deterministic stack usage.
- Fine‑grained control over the future’s poll implementation.
- Non‑Send futures (e.g., using
async_std::task::LocalSeton a single‑threaded runtime).
Then hand‑crafting the future or using a generic async fn that returns an inline future is preferable. The macro’s Send bound is a restriction in single‑threaded contexts.
Concrete Worked Example
Below is a minimal Cargo project that demonstrates the async‑trait macro, its expansion, and a manual alternative. The code is meant for illustration; it compiles on stable Rust 1.78 with async‑trait = "0.1".
// Cargo.toml
[dependencies]
async-trait = "0.1"
async-std = { version = "1.12", features = ["attributes"] }
// src/lib.rs
use async_trait::async_trait;
use async_std::task;
#[async_trait]
pub trait Greeter {
async fn greet(&self, name: &str) -> String;
}
pub struct Hello;
#[async_trait]
impl Greeter for Hello {
async fn greet(&self, name: &str) -> String {
format!("Hello, {}!", name)
}
}
// Manual implementation (no macro)
pub trait GreeterManual {
type Fut: std::future::Future + Send;
fn greet(&self, name: &str) -> Self::Fut;
}
impl GreeterManual for Hello {
type Fut = std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send>>;
fn greet(&self, name: &str) -> Self::Fut {
Box::pin(async move { format!("Hello, {}!", name) })
}
}
#[async_std::main]
async fn main() {
let greeter = Hello;
let msg = greeter.greet("Alice").await;
println!("{}", msg);
}
Running cargo run prints Hello, Alice!. To inspect the macro expansion, run:
rustc --pretty=expanded src/lib.rs | less
Look for the generated impl Future that is boxed inside the trait method.
Limitations & Caveats
- Heap allocation: Every async call creates a
Box. In performance‑critical code, consider a manual future. - Send requirement: The macro adds a
+ Sendbound by default. If you need a non‑Send future, you must useasync_trait(?Send)or manual implementation. - No const support: Boxed futures can’t be used in
constcontexts. - Nightly for generics: Using generic async trait methods with type parameters may require nightly features.
Actionable Takeaway
Use async‑trait when:
- You want ergonomic async trait definitions without boilerplate.
- Your runtime is multi‑threaded and the Send bound is acceptable.
- Performance impact of a boxed future is negligible for your workload.
Switch to a manual future when:
- Zero allocation or stack‑based futures are required.
- You need non‑Send futures in a single‑threaded context.
- Fine‑grained control over the future’s poll logic is necessary.
By weighing these trade‑offs, you can decide whether the async‑trait macro simplifies your code or whether a hand‑crafted future is the better fit for your project.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.