8 min readbackend

Async Rust finally grew up: async closures, async fn in traits, and the death of #[async_trait]

The Rust 2024 edition delivered the features async Rust was missing for years — async closures, async fn in traits, and the end of the #[async_trait] macro. Here's what changed and the one edge that still bites, from the architecture, developer, and DevOps angles.

On this page

# The Problem

You’re reading a compiler error for the third time and it still doesn’t parse in your head. Something about a boxed future, a lifetime that outlives something, a Pin you never wrote. You scroll up: the culprit is a trait with an async fn in it, wrapped in #[async_trait] — the macro that, for years, was the price of admission for doing async in a trait at all. It rewrote your methods into Box::pin(async move { ... }) behind your back, allocated on every call, and when something went wrong it handed you error messages about code you never typed.

For most of async Rust’s life, this was just how it was. Traits and async didn’t mix natively, so the ecosystem papered over it with a macro, and everyone learned to squint past the boxed-future noise.

That era is over. The Rust 2024 edition delivered the language features that make async traits native — no macro, no forced boxing, comprehensible errors. Axum 0.8 dropped its #[async_trait] requirement to prove it. Async Rust in 2026 is a genuinely different, more ergonomic language than it was two years ago. But there’s exactly one sharp edge left, and knowing precisely where it is separates people who enjoy async Rust from people who fight it.


# What Actually Shipped

The Rust 2024 edition (stable since Rust 1.85, February 2025), plus the releases around it, landed the long-awaited trio:

  • Async closuresasync || { ... }, backed by three new traits: AsyncFn, AsyncFnMut, AsyncFnOnce. This unlocks ergonomic higher-order async APIs (retry wrappers, middleware, combinators) that previously needed contortions.
  • async fn in traits with return-position impl Trait in traits (RPITIT) — you can write async fn directly in a trait definition, no macro required.
  • Refined lifetime-capture rules for impl Trait, closing a class of confusing borrow errors.

The proof it’s real: Axum 0.8 removed the #[async_trait] requirement, moving a whole web framework off the macro and onto native language features.

What 'grew up' means here

Native async fn in traits replaces a macro that hid Box::pin allocations and produced cryptic errors. The ergonomic gap between async and sync Rust is now mostly closed. “Mostly” is doing real work in that sentence — see the DevOps section for the one thing that still doesn’t work.


# The Architecture Perspective

For anyone designing libraries or service frameworks, this changes what you can express cleanly. The #[async_trait] era pushed a hidden cost into every abstraction boundary: define a trait for your handlers, your middleware, your storage backends — and each .await through that boundary paid a heap allocation for the boxed future, plus the cognitive tax of macro-generated errors leaking to your users.

Native async traits let your abstractions be zero-cost in the way the rest of Rust aims to be: static dispatch, no per-call boxing, and errors that point at your code. That means you can design trait-based plugin systems, storage abstractions, and middleware stacks without apologizing for them in the docs.

flowchart LR
    subgraph Old["Pre-2024 edition"]
        A["#[async_trait] macro"] --> B["Box::pin per call"] --> C["hidden heap alloc<br/>+ murky errors"]
    end
    subgraph New["Rust 2024 edition"]
        D["native async fn in traits"] --> E["static dispatch,<br/>no macro"] --> F["clearer errors"]
    end
    Old ==migrate==> New
    F -.->|"one edge remains"| G["dyn AsyncTrait<br/>not object-safe"]

    classDef old fill:#d97706,color:#fff,stroke:#92400e;
    classDef new fill:#059669,color:#fff,stroke:#065f46;
    classDef edge fill:#dc2626,color:#fff,stroke:#991b1b;
    class A,B,C old;
    class D,E,F new;
    class G edge;

The architectural catch — the edge in that diagram — is that async trait methods still aren’t dyn-object-safe. You can’t write Box<dyn MyAsyncTrait> when the trait has async methods, because the compiler can’t know the size of the returned future. If your design wants runtime polymorphism over async behaviors — a Vec<Box<dyn Handler>> plugin registry, say — you have to design around this, not wish it away. It shapes real API decisions.

Static vs. dynamic dispatch is now a real design fork

Native async traits are great at static dispatch (generics, impl Trait). The moment you need dynamic dispatch over async behavior, you hit the object-safety wall and must pick an escape hatch — boxed futures, enum dispatch, or the trait-variant crate. Decide which early; retrofitting it is painful.


# The Developer Perspective

Here’s the before and after that most developers will feel first. The old way:

// Pre-2024: the macro was mandatory for async in a trait.
#[async_trait]
trait Repository {
    async fn get(&self, id: Id) -> Result<User>;   // becomes Box::pin(async move ...)
}

The native way — same intent, no macro, no boxing:

// Rust 2024 edition: async fn in traits is a language feature.
trait Repository {
    async fn get(&self, id: Id) -> Result<User>;
}

Async closures unlock APIs that were awkward or impossible before. Here’s a generic retry combinator that takes an async operation directly:

// Takes an `AsyncFn` — this needed the new trait family.
async fn retry<F, T, E>(attempts: u32, op: F) -> Result<T, E>
where
    F: AsyncFn() -> Result<T, E>,
{
    let mut last = None;
    for _ in 0..attempts {
        match op().await {                 // call the async closure, await it
            Ok(v) => return Ok(v),
            Err(e) => last = Some(e),
        }
    }
    Err(last.unwrap())
}

// Call site is clean:
let user = retry(3, async || client.fetch(id).await).await?;

And the escape hatches for when you do need dyn:

// Option 1: box the future explicitly (you pay the alloc, but it's dyn-safe).
trait Handler {
    fn handle(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
}

// Option 2: the `trait-variant` crate generates a dyn-compatible variant for you.
// Option 3: enum dispatch when the set of implementors is closed.
Delete #[async_trait] where you can

On the 2024 edition, most traits can shed the macro outright — fewer dependencies, no forced boxing, and error messages that reference your actual code. Axum 0.8+ already did it; follow its lead for your own traits, and reach for an escape hatch only at the genuine dyn boundaries.

'Future is not Send' moves to trait bounds

The classic “your future isn’t Send” error doesn’t disappear — it relocates to trait bounds on the returned future. Learn return-type notation (RTN) and the trait-variant crate before you hit this inside a tower layer at 2 a.m., because the error can be dense.


# The DevOps Perspective

This is a language-features story, so the operational surface is mostly your build and toolchain — but there’s real coordination cost.

It’s an edition bump, and editions are a workspace-wide decision. These features require the 2024 edition and, in some cases, a recent enough compiler. Bumping the edition means bumping your MSRV (minimum supported Rust version) and adopting Cargo resolver v3. For a library, that’s a promise to your consumers; for a workspace, it’s a coordinated change across every crate. Plan it, pin it in CI, and communicate the MSRV move — don’t let it surprise a downstream consumer.

flowchart TD
    A["Adopt Rust 2024 edition"] --> B["Bump MSRV<br/>(coordinate across workspace)"]
    B --> C["Cargo resolver v3"]
    C --> D["Pin toolchain in CI<br/>(rust-toolchain.toml)"]
    D --> E["Drop #[async_trait];<br/>fewer deps to audit"]

    classDef step fill:#2563eb,color:#fff,stroke:#1e40af;
    classDef win fill:#059669,color:#fff,stroke:#065f46;
    class A,B,C,D step;
    class E win;

Dependency-tree slimming is a genuine ops win. Every trait that sheds #[async_trait] is one fewer proc-macro dependency in your build graph — faster clean builds and one less crate in your cargo audit surface. As the ecosystem follows Axum off the macro, this compounds.

Watch for the object-safety wall in review, not prod. The dyn-safety limitation is a compile-time error, not a runtime one — which is good, but it means an architectural refactor toward dynamic dispatch can hit a wall late. Surface it in design review so nobody discovers it mid-migration.

Pros

  • Native async fn in traits — no macro, no forced boxing
  • Async closures enable ergonomic higher-order async APIs
  • Clearer compiler errors that reference your own code
  • Fewer proc-macro deps → faster builds, smaller audit surface
  • Framework momentum (Axum 0.8) validates the migration

Cons

  • Async trait methods still aren't dyn-object-safe
  • 'Future not Send' errors relocate to trait bounds — dense to debug
  • Requires the 2024 edition, an MSRV bump, and resolver v3
  • dyn boundaries force an escape hatch (box/enum/trait-variant)
  • Edition move must be coordinated across a workspace

# Putting It Together

Async Rust in 2026 is a different, friendlier language than the #[async_trait]- everywhere years. Native async traits and async closures remove a layer of macro magic, hidden allocations, and inscrutable errors — the ergonomic gap with sync Rust is mostly closed. The honest caveat is the object-safety wall: dyn over async traits still needs an escape hatch, and that shapes real API design. Learn where the edge is, delete the macro everywhere else, and enjoy the language that async Rust finally became.


# References

views

Click to show appreciation

Discussion