What are the trade-offs between async_trait and async functions in traits (AFIT) stabilized in Rust 1.75?
async_trait is a procedural macro that transforms async trait methods into returning Pin<Box<dyn Future>>, while AFIT (async functions in traits) is native language support that allows async methods in traits without macrosβbut with limitations around dynamic dispatch that async_trait doesn't have. The core trade-off is between ergonomic, macro-based flexibility (async_trait) that works with trait objects, versus native, zero-overhead static dispatch (AFIT) that requires dyn support through the unstable return_position_impl_trait_in_trait feature. Use async_trait when you need trait objects or are supporting older Rust versions; use AFIT for static dispatch, no macro dependency, and when you don't need dynamic dispatch.
The async_trait Macro Approach
use async_trait::async_trait;
#[async_trait]
trait Database {
async fn get_user(&self, id: u64) -> Result<User, Error>;
async fn save_user(&self, user: &User) -> Result<(), Error>;
}
#[async_trait]
impl Database for PostgresDatabase {
async fn get_user(&self, id: u64) -> Result<User, Error> {
// Implementation is truly async
let row = self.query("SELECT * FROM users WHERE id = $1", &[&id]).await?;
Ok(User::from_row(row))
}
async fn save_user(&self, user: &User) -> Result<(), Error> {
self.execute("INSERT INTO users ...").await?;
Ok(())
}
}
#[derive(Debug)]
struct User;
struct PostgresDatabase;
impl PostgresDatabase {
fn query(&self, _q: &str, _args: &[&dyn std::any::Any]) -> Result<Row, Error> { Ok(Row) }
fn execute(&self, _q: &str) -> Result<(), Error> { Ok(()) }
}
struct Row;
impl User { fn from_row(_row: Row) -> Self { User } }
struct Error;async_trait transforms async methods into methods returning Pin<Box<dyn Future>>.
Native AFIT Approach
trait Database {
// Native async method in trait (Rust 1.75+)
async fn get_user(&self, id: u64) -> Result<User, Error>;
async fn save_user(&self, user: &User) -> Result<(), Error>;
}
impl Database for PostgresDatabase {
async fn get_user(&self, id: u64) -> Result<User, Error> {
let row = self.query("SELECT * FROM users WHERE id = $1", &[&id]).await?;
Ok(User::from_row(row))
}
async fn save_user(&self, user: &User) -> Result<(), Error> {
self.execute("INSERT INTO users ...").await?;
Ok(())
}
}AFIT is built into the language, no macro attribute needed.
What async_trait Generates
use async_trait::async_trait;
use std::future::Future;
use std::pin::Pin;
// What you write:
#[async_trait]
trait Service {
async fn process(&self, data: &[u8]) -> Vec<u8>;
}
// What async_trait generates (simplified):
trait Service {
fn process<'life0, 'async_trait>(
&'life0 self,
data: &'life0 [u8],
) -> Pin<Box<dyn Future<Output = Vec<u8>> + Send + 'async_trait>>
where
'life0: 'async_trait,
Self: Sync + 'async_trait;
}async_trait boxes every future, making it heap-allocated and type-erased.
AFIT Without Boxing
trait Service {
async fn process(&self, data: &[u8]) -> Vec<u8>;
}
impl Service for MyService {
async fn process(&self, data: &[u8]) -> Vec<u8> {
// Future is not boxed - compiler knows exact type
process_data(data).await
}
}
// With static dispatch, the future type is known at compile time
async fn use_service<S: Service>(service: &S, data: &[u8]) -> Vec<u8> {
service.process(data).await
}AFIT allows the compiler to monomorphize and inline without boxing.
Dynamic Dispatch: The Key Limitation
use async_trait::async_trait;
// async_trait: Works with dyn
#[async_trait]
trait AsyncService {
async fn process(&self) -> i32;
}
async fn use_dyn_with_async_trait(service: &dyn AsyncService) -> i32 {
service.process().await // Works!
}
// AFIT: Does NOT work with dyn (stable Rust)
trait NativeAsyncService {
async fn process(&self) -> i32;
}
// This does NOT compile in stable Rust 1.75:
// async fn use_dyn_with_afit(service: &dyn NativeAsyncService) -> i32 {
// service.process().await
// }
// Error: `async fn` in trait cannot be used with `dyn`async_trait works with dyn; AFIT requires unstable features for trait objects.
Using async_trait with Trait Objects
use async_trait::async_trait;
#[async_trait]
trait Handler {
async fn handle(&self, request: Request) -> Response;
}
struct Router {
handlers: Vec<Box<dyn Handler>>,
}
impl Router {
async fn route(&self, request: Request) -> Response {
for handler in &self.handlers {
let response = handler.handle(request.clone()).await;
if !response.is_empty() {
return response;
}
}
Response::not_found()
}
}
#[derive(Clone)]
struct Request;
struct Response;
impl Response { fn not_found() -> Self { Response } fn is_empty(&self) -> bool { true } }async_trait enables collections of trait objects with async methods.
AFIT with Static Dispatch Only
trait Handler {
async fn handle(&self, request: Request) -> Response;
}
// Works with generics (static dispatch)
async fn route<H: Handler>(handlers: &[H], request: Request) -> Response {
for handler in handlers {
let response = handler.handle(request.clone()).await;
if !response.is_empty() {
return response;
}
}
Response::not_found()
}
// Does NOT work with dyn:
// async fn route_dyn(handlers: &[&dyn Handler], request: Request) -> Response { ... }AFIT requires generic (static) dispatch unless you enable unstable features.
Performance: Boxing vs No Boxing
use async_trait::async_trait;
use std::time::Instant;
// async_trait: Every call boxes the future
#[async_trait]
trait BoxedAsync {
async fn compute(&self, n: u64) -> u64;
}
// AFIT: No boxing overhead
trait NativeAsync {
async fn compute(&self, n: u64) -> u64;
}
// Performance comparison (conceptual):
// - async_trait: Box allocation per call, dynamic dispatch overhead
// - AFIT: No allocation, potential for inlining, static dispatch
//
// In tight loops, AFIT can be significantly faster due to:
// 1. No heap allocation
// 2. Better inlining opportunities
// 3. No indirect function calls
struct Compute;
#[async_trait]
impl BoxedAsync for Compute {
async fn compute(&self, n: u64) -> u64 { n * 2 }
}
impl NativeAsync for Compute {
async fn compute(&self, n: u64) -> u64 { n * 2 }
}AFIT avoids heap allocation and enables better optimization.
Send Bound Differences
use async_trait::async_trait;
// async_trait: Requires explicit Send bound for + Send
#[async_trait]
trait AsyncService: Send + Sync {
async fn process(&self) -> i32;
}
// Without Send + Sync on the trait:
#[async_trait]
trait LocalAsyncService {
async fn process(&self) -> i32;
}
// AFIT: Futures are not automatically Send
trait NativeService {
async fn process(&self) -> i32;
}
// For spawning on tokio, need to ensure Send:
// AFIT futures are Send if all captured values are Send
// async_trait futures are Send if you use #[async_trait] (requires Send)
// or use local_async_trait for non-Send futuresasync_trait has explicit Send bound handling; AFIT infers from captured values.
Self Bounds and Lifetime Complexity
use async_trait::async_trait;
// async_trait handles complex lifetimes automatically
#[async_trait]
trait ComplexService {
async fn process<'a>(&self, data: &'a [u8]) -> &'a [u8];
}
// AFIT: Same signature works naturally
trait NativeComplexService {
async fn process<'a>(&self, data: &'a [u8]) -> &'a [u8];
}
// async_trait generates lifetime bounds automatically
// AFIT compiler handles lifetime inference naturallyBoth handle complex lifetimes, but AFIT does it through the type system.
Where Clauses with Self
use async_trait::async_trait;
// async_trait supports complex bounds
#[async_trait]
trait BoundedService: Clone {
async fn process(&self) -> i32
where
Self: Sized;
}
// AFIT supports bounds naturally
trait NativeBoundedService: Clone {
async fn process(&self) -> i32
where
Self: Sized;
}Both support trait bounds, but AFIT has cleaner syntax.
Default Implementations
use async_trait::async_trait;
// async_trait: Default implementations work
#[async_trait]
trait DefaultService {
async fn primary(&self) -> i32;
async fn secondary(&self) -> i32 {
self.primary().await * 2
}
}
// AFIT: Default implementations also work
trait NativeDefaultService {
async fn primary(&self) -> i32;
async fn secondary(&self) -> i32 {
self.primary().await * 2
}
}
// Both allow trait methods to call other async trait methodsBoth support default implementations that call other async methods.
Migration Strategy
// Phase 1: Keep async_trait for dyn compatibility
use async_trait::async_trait;
#[async_trait]
pub trait Repository {
async fn find(&self, id: u64) -> Option<Record>;
}
// Phase 2: Create a version without async_trait for static use
pub trait RepositoryStatic {
async fn find(&self, id: u64) -> Option<Record>;
}
// Phase 3: Once dyn support stabilizes, remove async_trait
pub trait RepositoryFinal {
async fn find(&self, id: u64) -> Option<Record>;
}
struct Record;Migrate incrementally based on your dyn requirements.
When to Use async_trait
use async_trait::async_trait;
#[async_trait]
trait Plugin {
async fn initialize(&mut self) -> Result<(), Error>;
async fn process(&self, input: &[u8]) -> Result<Vec<u8>, Error>;
}
// Use async_trait when:
// 1. You need trait objects (dyn Plugin)
// 2. You support older Rust versions (< 1.75)
// 3. You need to store heterogeneous implementations in collections
// 4. You're building a plugin system with dynamic loading
struct PluginManager {
plugins: Vec<Box<dyn Plugin>>,
}
impl PluginManager {
async fn initialize_all(&mut self) -> Result<(), Error> {
for plugin in &mut self.plugins {
plugin.initialize().await?;
}
Ok(())
}
}
struct Error;Use async_trait when dynamic dispatch is required.
When to Use AFIT
// Use AFIT when:
// 1. You only use static dispatch (generics)
// 2. You want zero-overhead async traits
// 3. You don't need trait objects
// 4. You want cleaner code without macro dependency
trait HttpClient {
async fn get(&self, url: &str) -> Result<String, Error>;
async fn post(&self, url: &str, body: &[u8]) -> Result<String, Error>;
}
// Generic function using the trait
async fn fetch_user<H: HttpClient>(client: &H, id: u64) -> Result<User, Error> {
let url = format!("https://api.example.com/users/{}", id);
let body = client.get(&url).await?;
let user: User = serde_json::from_str(&body)?;
Ok(user)
}
// No trait objects needed, AFIT is perfect here
struct User;
struct Error;
mod serde_json { pub fn from_str<T>(_s: &str) -> Result<T, super::Error> { Err(super::Error) } }Use AFIT when static dispatch suffices.
Combining Both Approaches
use async_trait::async_trait;
// Define core trait with AFIT (for static dispatch)
trait ServiceCore {
async fn process(&self, data: &[u8]) -> Vec<u8>;
}
// Wrapper for dynamic dispatch using async_trait
#[async_trait]
trait ServiceDyn {
async fn process(&self, data: &[u8]) -> Vec<u8>;
}
// Blanket implementation
impl<T: ServiceCore + ?Sized> ServiceDyn for T {
async fn process(&self, data: &[u8]) -> Vec<u8> {
ServiceCore::process(self, data).await
}
}
// Use ServiceCore when you can use generics
// Use ServiceDyn when you need dynProvide both interfaces to give users flexibility.
Unstable Features for dyn AFIT
// In nightly Rust with unstable features:
// #![feature(return_position_impl_trait_in_trait)]
// This will allow:
trait AsyncService {
async fn process(&self) -> i32;
}
async fn use_dyn(service: &dyn AsyncService) -> i32 {
service.process().await // Eventually will work!
}
// But as of Rust 1.75, this requires nightly featuresAFIT with dyn support is unstable and requires nightly Rust.
Error Messages
use async_trait::async_trait;
// async_trait error messages can be confusing due to macro expansion
#[async_trait]
trait BadService {
async fn process(&self, data: String) -> &str; // Lifetime error
// Error messages reference generated code, not your source
}
// AFIT error messages are clearer
trait NativeBadService {
async fn process(&self, data: String) -> &str; // Lifetime error
// Error message directly points to your code
}AFIT provides clearer error messages since there's no macro expansion.
Dependency Considerations
// async_trait adds a dependency
// Cargo.toml:
// [dependencies]
// async-trait = "0.1"
// AFIT requires no dependencies
// Available in stable Rust 1.75+
// async_trait versions need to be consistent across the crate graph
// AFIT is standardized in the language, no version issuesAFIT eliminates a dependency and version coordination.
Summary Comparison
// async_trait
// β
Works with trait objects (dyn)
// β
Supports older Rust versions
// β
Mature, well-tested
// β Heap allocation (Box<dyn Future>)
// β Macro-based, harder error messages
// β Additional dependency
// β Runtime overhead from boxing
// AFIT (async functions in traits)
// β
Zero overhead, no boxing
// β
Native language support
// β
Clearer error messages
// β
No dependencies
// β No dyn support (stable Rust)
// β Requires Rust 1.75+
// β dyn support requires nightlyKey insight: async_trait and AFIT serve overlapping but distinct use cases. async_trait remains necessary when you need trait objects (dyn Trait) with async methods in stable Rustβit boxes futures to erase their types, enabling dynamic dispatch at the cost of heap allocation. AFIT is the modern, zero-overhead approach for static dispatch scenarios where you only need generic (monomorphized) code. The ideal migration path is to use AFIT for all new code that doesn't require dyn, keeping async_trait only where dynamic dispatch is essential. Once return_position_impl_trait_in_trait stabilizes, async_trait can be fully retired, but for now, both have their place in async Rust ecosystems.
