A long time coming

In December 2023, Rust finally shipped native support for async fn in traits. Free-standing async functions and async functions in impl blocks have existed since Rust 1.39, but trait support had to wait years for the type system to catch up.

pub async fn read_hosts() -> eyre::Result<Vec<u8>> { // etc. }

Before this landed, the recommended workaround for years was the async-trait crate. It worked, but at a cost: the macro rewrote trait definitions and implementations to box their futures.

Boxed futures are futures allocated on the heap. They're necessary because the future returned by an async function can vary wildly in size depending on how much state that function holds onto across suspension points.

Consider two simple async functions. One sleeps briefly with little local state; another carries a large array across an async sleep. The second future has to retain that array in its state machine, so its future is far larger. This matters when calling code needs to reserve space for a return value: the size must be known ahead of time. Stack frames are laid out based on this requirement — when a function has several async calls to sequence, the compiler reserves space for each step's future right at function entry.

async fn foo() { tokio::time::sleep(std::time::Duration::from_secs(1)).await; println!("done"); }
sansioex on  main [!] via 🦀 v1.83.0 ❯ cargo asm sansioex::main --quiet --simplify --color | head -5 sansioex::main: Lfunc_begin45: sub sp, sp, #256 stp x20, x19, [sp, #224] stp x29, x30, [sp, #240]

Dynamic dispatch and vtables

The problem becomes acute with trait objects. If you have a &dyn AsyncRead and call its read method, how much space do you reserve for the resulting future? The answer depends entirely on the concrete type behind the trait object.

The long-term plan involves unsized locals, where the size of a returned future would become part of the trait object's vtable. For now, though, the only practical way to hold an arbitrary future is to heap-allocate it.

A boxed trait object is two pointers wide: one to the data, one to a vtable of function pointers. The vtable includes things like the Drop implementation, so the runtime knows how to free the value. Interestingly, this two-pointer layout applies specifically to boxed trait objects — a plain Box<T> for a concrete type is just one pointer. LLDB inspection of a Box<dyn Future> confirms both pointers are present, and one of them points into the async function's generated code.

The async-trait macro exploits exactly this. Given a trait like:

#[async_trait::async_trait] trait AsyncRead { async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; }

It produces a rewritten version where every async method returns a Pin<Box<dyn Future>>:

trait AsyncRead { #[must_use] #[allow( elided_named_lifetimes, clippy::type_complexity, clippy::type_repetition_in_bounds )] fn read<'life0, 'life1, 'async_trait>( &'life0 mut self, buf: &'life1 mut [u8], ) -> ::core::pin::Pin< Box< dyn ::core::future::Future<Output = io::Result<usize>> + ::core::marker::Send + 'async_trait, >, > where 'life0: 'async_trait, 'life1: 'async_trait, Self: 'async_trait; }

The resulting future is always 16 bytes, regardless of the concrete implementation. It works, but it forces a heap allocation on every call.

Native async traits, but not dyn-compatible

As of Rust 1.75, you can write async functions in traits directly without any macro:

use std::io; trait AsyncRead { async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>; }

Implementing the trait for your own types works naturally, and the futures returned are sizeless-agnostic — they can be stored on the stack. In fact, if you measure the size of such a future, you'll find it's a concrete size like 224 bytes for a particular type, not the 16 bytes a boxed version would produce. For any type you own and can reason about, this is perfectly fine as a value.

What doesn't work is using such a trait as a trait object. The compiler rejects &dyn AsyncRead because the trait has an implicit associated type (the future from each async method), and that makes it dyn-incompatible. The diagnostics are clear on this point: the trait cannot be made into an object because it has the associated type read::Future<_>.

This restriction is not unique to async. Old Rust has many dyn-incompatible trait patterns; taking self by value in a trait method is one classic example. Here too, boxing the receiver works: a method that takes Box<Self> by value is fine for trait objects, because a pointer has a known size.

The practical consequence: async traits work great today, as long as you work with concrete types or use impl Trait arguments.

fn use_reader(_reader: impl AsyncRead) {}

Since monomorphization instantiates a fresh copy of the function for every concrete argument type, the returned future can be sized per call site, and everything works.

What async fn in trait really means

When you write an async fn inside a trait definition, you're declaring a function that returns "something that implements Future". Just as impl Trait in argument position desugars to a generic type parameter, in return position it becomes an associated type on the trait.

On nightly Rust, this pattern is straightforward to implement directly. The tower crate's Service trait has long used this shape, exposing a Future associated type. Implementing that trait historically meant choosing between hand-writing a future to avoid heap allocation, boxing the future as Pin<Box<dyn Future<...>>>, or opting into the nightly-only impl_trait_in_assoc_type feature.

A simpler Service on stable

Rust 1.75 changed the picture. With async fn supported directly in traits on stable, a much simpler version of the Service trait becomes possible:

trait Service<Request> { type Response; type Error; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>; async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>; }

Implementations get noticeably cleaner. A no-op service and a logging service can both be written without boxing or manual future plumbing, and they compile on stable Rust 1.75 as-is.

impl<Request> Service<Request> for () { type Response = (); type Error = (); fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { Poll::Ready(Ok(())) } async fn call(&mut self, _request: Request) -> Result<Self::Response, Self::Error> { Ok(()) } }
impl<S, Request> Service<Request> for LogRequest<S> where S: Service<Request>, Request: std::fmt::Debug, { type Response = S::Response; type Error = S::Error; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error> { println!("{:?}", request); self.inner.call(request).await } }
#[tokio::main] async fn main() { let mut service = LogRequest { inner: () }; // (note: we assume the service is ready) let fut = service.call(()); print_type_name_and_size(&fut); fut.await.unwrap(); }
sansioex on  main [!] via 🦀 v1.83.0 ❯ cargo +1.75 run --quiet <sansioex::LogRequest<()> as sansioex::Service<()>>::call::{{closure}} 32 bytes ()

That simplicity comes with constraints worth understanding.

You can't name the future anymore

With associated types, code could refer to the concrete return type of Service::call. Tower's built-in Either service relies on naming that type to dispatch between two inner services. In the simplified trait, that need disappears — implementing Service for an Either enum is more natural with async fn, since the compiler handles the conditional future internally. For this particular case, the new syntax is a clear win.

Problems surface when you need to constrain the returned future beyond what the bare syntax allows — particularly around lifetimes and Send.

Lifetimes and what gets captured

Rust requires you to be explicit about whether a returned value borrows from its input. That's manageable with ordinary functions, but async functions add a wrinkle: the future returned by an async fn can hold onto borrowed state across await points, and the compiler must know which lifetimes are captured.

There's a subtle mismatch between the classic tower Service trait and the simplified version. With tower's associated type, the returned future cannot borrow from self. An implementation for i32 that adds self to each request and returns the result as a future fails to compile for exactly this reason:

#![feature(impl_trait_in_assoc_type)] impl Service<i32> for i32 { type Response = i32; type Error = (); type Future = impl Future<Output = Result<Self::Response, Self::Error>>; fn call(&mut self, request: i32) -> Self::Future { async move { Ok(*self + request) } } }
sansioex on  main [✘!+] via 🦀 v1.85.0-nightly ❯ cargo +nightly check --quiet error[E0700]: hidden type for `<i32 as Service<i32>>::Future` captures lifetime that does not appear in bounds --> src/main.rs:19:9 | 16 | type Future = impl Future<Output = Result<Self::Response, Self::Error>>; | --------------------------------------------------------- opaque type defined here 17 | 18 | fn call(&mut self, request: i32) -> Self::Future { | --------- hidden type `{async block@src/main.rs:19:9: 19:19}` captures the anonymous lifetime defined here 19 | async move { Ok(*self + request) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ For more information about this error, try `rustc --explain E0700`. error: could not compile `sansioex` (bin "sansioex") due to 1 previous error

Generic associated types (GATs), stabilized in Rust 1.65, would solve this — making the associated type generic over a lifetime allows the future to borrow from self. Tower predates that stabilization, which is why its trait isn't defined that way. Working around the limitation means doing whatever you need with self before constructing the future.

sansioex on  main [!] via 🦀 v1.85.0-nightly ❯ gwd diff --git a/src/main.rs b/src/main.rs index 9fb8ffb..bcdbed2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,8 @@ impl Service<i32> for i32 { type Future = impl Future<Output = Result<Self::Response, Self::Error>>; fn call(&mut self, request: i32) -> Self::Future { - async move { Ok(*self + request) } + let this = *self; + async move { Ok(this + request) } } }

With the simplified trait on Rust 1.75 stable, borrowing from self works out of the box:

trait Service<Request> { type Response; type Error; async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>; } impl Service<i32> for i32 { type Response = i32; type Error = (); async fn call(&mut self, request: i32) -> Result<Self::Response, Self::Error> { Ok(*self + request) } } #[tokio::main] async fn main() { let mut service: i32 = 1990; let res = service.call(34).await.unwrap(); println!("Result: \x1b[1;32m{res}\x1b[0m"); }
sansioex on  main [!+] via 🦀 v1.83.0 ❯ cargo run --quiet Result: 1990

But that permissiveness is itself a breaking change. Tower's design guarantees that calling service.call() multiple times yields multiple independent, owned futures that can run concurrently. The simplified trait can't make that guarantee, because a future might hold a borrow into self, preventing multiple in-flight calls:

#[tokio::main] async fn main() { let mut service: i32 = 2024; let fut1 = service.call(-34); let fut2 = service.call(-25); let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); println!("Got responses: {response1:?}, {response2:?}"); }
sansioex on  main [!] via 🦀 v1.83.0 ❯ cargo check --quiet error[E0499]: cannot borrow `service` as mutable more than once at a time --> src/main.rs:22:16 | 21 | let fut1 = service.call(-34); | ------- first mutable borrow occurs here 22 | let fut2 = service.call(-25); | ^^^^^^^ second mutable borrow occurs here 23 | 24 | let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); | ---- first borrow later used here For more information about this error, try `rustc --explain E0499`. error: could not compile `sansioex` (bin "sansioex") due to 1 previous error

Why rustc warns on public traits

This trade-off is the reason the compiler warns when you use async fn in a public trait: the syntax doesn't let you express extra bounds on the returned future. If you try to bring the simplified trait closer to tower by requiring a 'static future:

sansioex on  main [!] via 🦀 v1.83.0 ❯ gwd diff --git a/src/main.rs b/src/main.rs index 6d5223f..7947a70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,13 @@ +use std::future::Future; + pub trait Service<Request> { type Response; type Error; - async fn call(&mut self, request: Request) -> Result<Self::Response, Self::Error>; + fn call( + &mut self, + request: Request, + ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static; } impl Service<i32> for i32 {

...the corresponding implementation breaks in ways that aren't obvious from the error message. The fix is to abandon the async fn syntax in the implementation and return an impl Future from an explicit block instead:

sansioex on  main [!+] via 🦀 v1.83.0 ❯ gwd diff --git a/src/main.rs b/src/main.rs index 7947a70..b57b520 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,12 @@ impl Service<i32> for i32 { type Response = i32; type Error = (); - async fn call(&mut self, request: i32) -> Result<Self::Response, Self::Error> { - Ok(*self + request) + fn call( + &mut self, + request: i32, + ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static { + let this = *self; + async move { Ok(this + request) } } }

That change restores the ability to have several requests in flight at once:

#[tokio::main] async fn main() { let mut service: i32 = 2024; let fut1 = service.call(-34); let fut2 = service.call(-25); let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); println!("Got responses: {response1:?}, {response2:?}"); }
sansioex on  main [!+] via 🦀 v1.83.0 ❯ cargo run --quiet Got responses: 1990, 1999

Send-ness is inferred, not declared

With that concrete implementation in place, spawning the returned futures on tokio's runtime works — even though nothing in the trait definition requires the future to be Send:

sansioex on  main [!] via 🦀 v1.83.0 ❯ gwd diff --git a/src/main.rs b/src/main.rs index 16e6549..ae009d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,8 +27,8 @@ impl Service<i32> for i32 { async fn main() { let mut service: i32 = 2024; - let fut1 = service.call(-34); - let fut2 = service.call(-25); + let fut1 = tokio::spawn(service.call(-34)); + let fut2 = tokio::spawn(service.call(-25)); let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); println!("Got responses: {response1:?}, {response2:?}");
sansioex on  main [!] via 🦀 v1.83.0 ❯ cargo run --quiet Got responses: Ok(1990), Ok(1999)
Cool bear
// from the tokio sources #[track_caller] pub fn spawn<F>(future: F) -> JoinHandle<F::Output> where F: Future + Send + 'static, F::Output: Send + 'static, { // ✂️ }
Cool bear

The compiler sees the concrete type of <i32 as Service<i32>>::call and knows it happens to be Send. If it weren't, spawning would fail with an error. The same problem resurfaces in generic code: a function accepting any Service can't tokio::spawn the result of call(), because there's no bound stating the future is Send. You can constrain Response and Error at the call site, but the future itself isn't declared Send anywhere — that decision lives in the trait declaration.

#[tokio::main] async fn main() { let mut service: i32 = 2024; do_the_spawning(&mut service).await; } async fn do_the_spawning<S>(service: &mut S) where S: Service<i32>, { let fut1 = tokio::spawn(service.call(-34)); let fut2 = tokio::spawn(service.call(-25)); let (response1, response2) = tokio::try_join!(fut1, fut2).unwrap(); println!("Got responses: {response1:?}, {response2:?}"); }
@@ -32,6 +32,8 @@ async fn main() { async fn do_the_spawning<S>(service: &mut S) where S: Service<i32>, + S::Response: Send + std::fmt::Debug + 'static, + S::Error: Send + std::fmt::Debug + 'static, { let fut1 = tokio::spawn(service.call(-34)); let fut2 = tokio::spawn(service.call(-25));
error[E0277]: `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static` cannot be sent between threads safely --> src/main.rs:38:29 | 38 | let fut1 = tokio::spawn(service.call(-34)); | ------------ ^^^^^^^^^^^^^^^^^ `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static` cannot be sent between threads safely | | | required by a bound introduced by this call | = help: the trait `Send` is not implemented for `impl Future<Output = Result<<S as Service<i32>>::Response, <S as Service<i32>>::Error>> + 'static` note: required by a bound in `tokio::spawn` --> /Users/amos/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.42.0/src/task/spawn.rs:168:21 | 166 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output> | ----- required by a bound in this function 167 | where 168 | F: Future + Send + 'static, | ^^^^ required by this bound in `spawn`

With the associated-type approach, you can add such a bound at the trait level:

sansioex on  main [!⇡] via 🦀 v1.83.0 ❯ gwd diff --git a/src/main.rs b/src/main.rs index b32c487..3ff8f6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ pub trait Service<Request> { fn call( &mut self, request: Request, - ) -> impl Future<Output = Result<Self::Response, Self::Error>> + 'static; + ) -> impl Future<Output = Result<Self::Response, Self::Error>> + Send + 'static; } impl Service<i32> for i32 {

The simplified trait, for all its ergonomic gains, ends up strictly less versatile than the original tower Service trait.

What's next for async traits

The async working group is shipping crates to close the remaining gaps. trait-variant lets you declare both Send and non-Send versions of a trait in one definition, and dynosaur enables dynamic dispatch on traits using async fn. The larger milestone to watch for is dyn async traits — first-class support for trait objects with async methods, which would resolve many of the limitations described above.