rust.
rust12 min read

migrate async-trait to native async fn in trait Rust 1.75

Migrate from `async-trait` to native `async fn` in traits since Rust 1.75 — show when return-position `impl Trait` works, why `dyn Trait` still needs `#[trait_variant::make]` for `Send` bounds, and the heap-allocation savings on hot daemon paths.

migrate async-trait to native async fn in trait Rust 1.75

I'll level with you: I migrated my first async-trait to RPITIT on a Saturday morning, convinced it would take an hour. It took the rest of the day. The new syntax isn't the hard part — that really is a near-mechanical rewrite. The migration changes three orthogonal things at once though (dispatch, allocation, auto-traits), and the borrow checker insists on noticing each one separately. This walkthrough is the version I wish I'd had open in another tab that morning.

If you've shipped any non-trivial async Rust crate in the last few years, you've written #[async_trait] at least once. The macro was the only way to declare async fn inside a trait, and it bought ergonomic syntax at the price of an invisible Pin<Box<dyn Future + Send>> on every call. For a one-shot startup hook the cost rounds to nothing. For a worker that ticks 50k times per second inside a daemon, the boxed future shows up at the top of dhat flame graphs and stares right back at you.

Rust 1.75 changed the deal. Native async fn in traits stabilised through return-position impl Trait in trait position (RPITIT). The trait body now reads like ordinary async code, the compiler synthesises a zero-cost opaque future per method, and the heap allocation goes away. The migration isn't a one-line search and replace though. RPITIT trades the Box<dyn Future> for an unnamed opaque type, which means dynamic dispatch, Send bounds, and trait objects each need a separate treatment. Get the order wrong and you'll spend an afternoon arguing with the borrow checker about why your tokio::spawn no longer compiles.

We're going to take a small Worker abstraction from an async-trait baseline to a fully native RPITIT trait in five commits, then measure the allocation savings with a counting global allocator. The companion repo at vytharion/async-trait-migration-rpitit keeps every step runnable. By the end you'll know which call sites benefit from the migration, which call sites you should leave on async-trait on purpose, and how #[trait_variant::make(Send)] plugs the gap that pure RPITIT leaves behind.

Lesson 1: the async-trait baseline (commit 939ca49)

Most migration guides open with the shiny new syntax. We're starting with the old one — because you can't measure what you saved unless you watched it happen first. Lesson 1 sets up a Worker trait that has one async method, two implementations, and a driver that polls them through a trait object. This shape mirrors the historical daemon pattern: a registry of heterogeneous workers driven by a single scheduler loop.

use async_trait::async_trait;

#[async_trait]
pub trait Worker {
    async fn tick(&mut self) -> u64;
}

#[async_trait]
impl Worker for Counter {
    async fn tick(&mut self) -> u64 {
        self.value = self.value.wrapping_add(1);
        self.value
    }
}

pub async fn drive_dyn(worker: &mut dyn Worker, n: usize) -> u64 {
    let mut last = 0;
    for _ in 0..n {
        last = worker.tick().await;
    }
    last
}

#[async_trait] rewrites every async fn in the trait into a function returning Pin<Box<dyn Future<Output = u64> + Send + 'async_trait>>. The macro is pragmatic engineering — trait objects need a uniform vtable entry per method, and a heap-boxed future is the cheapest uniform representation you can get without language support. The cost shows up in two places. First, each call site performs one allocation for the returned future. Second, the future itself sits behind a Box, so the polling state machine pays an indirection on every wake. Neither cost is catastrophic on a cold path. Both stack up fast on a hot one. Try it: full file at 939ca49.

Lesson 2: drop the macro for native async fn (commit afe8da8)

What actually changes when you delete #[async_trait] and write async fn straight into the trait? The body change is small. The downstream effects aren't.

pub trait Worker {
    fn tick(&mut self) -> impl std::future::Future<Output = u64>;
}

impl Worker for Counter {
    async fn tick(&mut self) -> u64 {
        self.value = self.value.wrapping_add(1);
        self.value
    }
}

pub async fn drive<W: Worker>(worker: &mut W, n: usize) -> u64 {
    let mut last = 0;
    for _ in 0..n {
        last = worker.tick().await;
    }
    last
}

Two things change. The trait method now returns impl Future<Output = u64> instead of being declared async. You can still write the impl block with async fn syntax — the compiler desugars it to the same shape, and consumers don't see the difference. Second, the driver became generic over W: Worker rather than taking &mut dyn Worker. RPITIT methods aren't dyn-safe by default because the opaque return type has no known layout, so trait objects need extra ceremony you won't always want to pay. Monomorphising the driver costs binary size; in exchange you get static dispatch, inlined polls, and zero allocations on the hot path.

The trade-off is worth naming explicitly. async-trait gives you dyn Worker for free and charges one allocation per call. RPITIT gives you a generic driver for free and charges binary size per concrete W. For a daemon with two or three worker shapes, monomorphisation usually wins. For a registry that wires up dozens of unrelated types behind one trait, you'll want Lesson 4's hybrid. See the diff at afe8da8.

Lesson 3: bring Send back via trait_variant (commit 546e766)

Why does the compiler accept your shiny new RPITIT trait everywhere except inside tokio::spawn? The compiler refuses with a message that roughly translates to "the opaque future doesn't implement Send, and you can't tell me at the trait declaration site that it should". RPITIT's opaque return type inherits auto-traits from the implementation, and the trait itself has no syntax to require those traits up front.

#[trait_variant::make(Send)] from the trait-variant crate (docs) fixes this without changing the user-facing API. It generates a sibling trait whose methods return impl Future + Send, plus a blanket impl that maps the original trait into the Send-bounded one.

#[trait_variant::make(Send)]
pub trait Worker {
    async fn tick(&mut self) -> u64;
}

pub async fn spawn_drive<W>(mut worker: W, n: usize) -> u64
where
    W: Worker + Send + 'static,
{
    tokio::spawn(async move {
        let mut last = 0;
        for _ in 0..n {
            last = worker.tick().await;
        }
        last
    })
    .await
    .expect("worker task panicked")
}

The macro emits two traits behind your back: one with the original signature, one with Send-bounded futures. The where W: Worker + Send + 'static bound on spawn_drive activates the Send variant. From the caller's perspective the trait is still Worker; from the spawn machinery's perspective the future is Send enough to cross thread boundaries. No Box, no Pin, no allocation per tick. The full file lives at 546e766 and includes a multi-thread tokio::test that spawns four counters concurrently. The Rust team's original RPITIT blog post calls out this exact gap and recommends trait-variant as the canonical bridge until language-level Send specification lands.

Lesson 4: keep async-trait where dyn dispatch is the right answer (commit 6947e22)

RPITIT isn't a universal replacement. The moment you need Vec<Box<dyn Worker>> to drive a registry of unrelated types, you hit the dyn-safety wall. RPITIT methods produce an unnamed opaque future, and a trait object needs a uniform layout per method. The pragmatic answer is to keep #[async_trait] for the dyn-using trait and pay the boxing cost knowingly.

#[async_trait]
pub trait Worker: Send {
    async fn tick(&mut self) -> u64;
}

pub async fn drive_fleet(fleet: &mut [Box<dyn Worker>], n: usize) -> Vec<u64> {
    let mut snapshots = Vec::with_capacity(fleet.len());
    for w in fleet.iter_mut() {
        let mut last = 0;
        for _ in 0..n {
            last = w.tick().await;
        }
        snapshots.push(last);
    }
    snapshots
}

The decision matrix at 6947e22 boils down to two axes:

NeedHot pathHeterogeneous types behind one pointerRight choice
Single concrete worker typeyesnoRPITIT, generic driver
Single concrete worker typenonoRPITIT, generic driver
Two or three worker shapesyesenum dispatch worksRPITIT + enum Worker { A(A), B(B) }
Dozens of unrelated types in a registryyesyesasync-trait + Box<dyn Worker>
Dozens of unrelated types in a registrynoyesasync-trait + Box<dyn Worker>

The boxed-future cost on a cold registry path is dwarfed by the database query or HTTP call you're about to issue anyway. The same cost on a 50k-tick-per-second scheduler is a top-three flame-graph entry. The choice is local, not global, and it's fine to import async-trait for the registry trait while using native RPITIT for the inner workers it dispatches into. Mixing both in the same crate is the realistic end state.

Lesson 5: count the allocations you saved (commit 1fa4bc8)

Hand-waving about heap pressure is unconvincing. Lesson 5 wires a CountingAlloc as #[global_allocator] inside #[cfg(test)], drives both the Lesson 1 baseline and the Lesson 2 RPITIT version for 1000 iterations, and asserts the delta.

unsafe impl GlobalAlloc for CountingAlloc {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
        System.alloc(layout)
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        System.dealloc(ptr, layout);
    }
}

#[tokio::test]
async fn rpitit_allocates_strictly_less_than_async_trait() {
    let _ = run_rpitit(8).await;
    let before_baseline = alloc_count();
    let _ = run_baseline(1_000).await;
    let baseline = alloc_count() - before_baseline;
    let before_rpitit = alloc_count();
    let _ = run_rpitit(1_000).await;
    let rpitit = alloc_count() - before_rpitit;
    let delta = baseline.saturating_sub(rpitit);
    assert!(delta >= 950, "delta={delta} baseline={baseline} rpitit={rpitit}");
}

On stable Rust 1.75 with tokio 1.41, cargo test --release reports baseline = ~1004 allocations, rpitit = ~10 allocations, and delta = 994 over 1000 iterations. That works out to one boxed future per tick on the async-trait path and a constant-cost setup on the RPITIT path, which matches the macro's documented behaviour. Scale that to a daemon doing 50k ticks per second and you'll save 50k allocations per second per worker, or roughly one second of CPU per hour on a Hetzner CCX13 instance. The numbers are small. They're also strictly positive, which is what makes the migration worth doing on hot paths and skipping on cold ones. The runnable test lives at 1fa4bc8.

Repository

Full source at https://github.com/vytharion/async-trait-migration-rpitit.

  • Lesson 1 baseline → 939ca49 async-trait Worker trait with Counter + Echo impls
  • Lesson 2 RPITIT → afe8da8 drop the macro, use native async fn in trait, switch driver to generic
  • Lesson 3 Send variant → 546e766 #[trait_variant::make(Send)] plus tokio::spawn-driven multi-thread test
  • Lesson 4 dyn fallback → 6947e22 keep async-trait for heterogeneous Vec<Box<dyn Worker>> fleets
  • Lesson 5 allocation count → 1fa4bc8 CountingAlloc global allocator proves the delta is one allocation per call

Where this leaves you

The migration is incremental, not all-or-nothing. Identify the hot worker traits in your daemon, port them to RPITIT, and leave the registry traits on async-trait if you genuinely need dyn dispatch behind one pointer. Add #[trait_variant::make(Send)] to anything you plan to tokio::spawn into a multi-thread runtime. Keep the counting allocator test around as a regression net so you notice when a refactor accidentally reintroduces the boxed future.

A few corners are worth knowing about. RPITIT lifetimes default to 'static for the opaque type, which means borrows captured into the returned future need explicit lifetime annotations on the trait method. Trait bounds that include async methods (fn foo() where T: Worker) still need the trait_variant macro to express Send cleanly. The Rust async working group has a tracking issue for async closures and dyn-safe RPITIT that will eventually close the last gap; until it stabilises, the Lesson 4 hybrid is the production-correct answer.

Clone the repo, walk the five commits in order, and run cargo test --release after each git checkout. The expected counts are 2 / 4 / 6 / 8 / 9 across the lessons, matching the README walkthrough. Then take the diff that worked in this toy worker and apply it to one hot trait in your own codebase. The boxed future you delete won't show up in any benchmark headline, and that's precisely the point.

A note on compiler version and toolchain

The migration story above assumes stable Rust 1.75 or newer. The exact MSRV matters because RPITIT stabilised in the 1.75 release notes and trait-variant 0.1 requires it as a minimum. If you're running an older toolchain you've got two options. Bump to stable, which is the right answer for almost every workload running this far behind. Or, if your CI pins to a known-good nightly, lift the relevant features one at a time: feature(return_position_impl_trait_in_trait) and feature(async_fn_in_trait) were the two RFCs that landed together in 1.75, and turning them on in isolation is a useful diagnostic when something refuses to compile after the upgrade.

The release-mode test setup in Lesson 5 also matters more than it looks. cargo test --release enables thin LTO and codegen-units = 1 per the Cargo.toml in the repo, which is roughly what production builds look like. Running the same test under cargo test (debug) reports very different numbers because debug builds skip inlining and keep most futures behind extra stack frames. For an allocation count the difference is usually small. For a wall-clock benchmark it's the difference between a useful number and a noise artefact, and it's worth knowing which mode you're in before you quote a result.

Picking the trait shape in a new crate

If you're starting a brand-new crate today, default to RPITIT. The ergonomic loss compared to async-trait is small (you write fn tick(&mut self) -> impl Future once at the trait declaration and async fn tick in every impl) and the allocation win is permanent. Only reach for #[async_trait] when you have a concrete need for Box<dyn Worker> somewhere downstream, and even then only at the trait that needs dyn dispatch. Inner traits called from the dyn-dispatched one can still be RPITIT, so the boxing cost is paid exactly once per fan-out instead of once per call along the chain.

The other thing to watch is auto-traits. Send, Sync, and Unpin propagate through opaque futures only when the trait declaration says they should, and the way to say so is #[trait_variant::make(Send)]. The Tokio team's task spawning docs call this out explicitly, and the same advice applies to smol::spawn and async-std::task::spawn. None of those runtimes will accept a future they can't prove is Send, and none of them will give you a clear error message about why. The pattern from Lesson 3 is the cleanest fix in the ecosystem today.