Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /Rust Async Traits Still Aren't Object-Safe: The Real Fixes
rust12 min read

Rust Async Traits Still Aren't Object-Safe: The Real Fixes

Async fn in traits still isn't dyn-compatible in Rust. Here are the three real workarounds (enum dispatch, async-trait, RPITIT) and when to use each one.

Zeeshan Tofiq
Zeeshan Tofiq
July 24, 2026
On this page

On this page

  • What's Actually Stable in Async Rust Right Now
  • Why Object Safety Is Still Broken for Async
  • Workaround 1: Enum Dispatch
  • Workaround 2: The async-trait Crate
  • Workaround 3: RPITIT With an Object-Safe Adapter
  • The Three Workarounds Side by Side
  • Which One Should You Actually Pick
  • Frequently Asked Questions

Short answer: as of early 2026, async fn in traits is stable and fast for static dispatch, but it still does not make a trait object-safe. You cannot write dyn MyTrait when MyTrait has async methods. Pick one of three workarounds depending on whether your implementors are a closed set (enum dispatch), an open set you don't control (the async-trait crate), or you're writing a library where the boxing cost matters to your users (RPITIT with an object-safe adapter trait).

You write a trait with an async method, implement it for a couple of types, then try to put them behind a shared interface the way you always have with sync traits:

rust — main.rs
trait Notifier {
    async fn send(&self, msg: &str) -> Result<(), Error>;
}

struct Email;
struct Sms;

impl Notifier for Email {
    async fn send(&self, msg: &str) -> Result<(), Error> { /* ... */ Ok(()) }
}
impl Notifier for Sms {
    async fn send(&self, msg: &str) -> Result<(), Error> { /* ... */ Ok(()) }
}

let notifiers: Vec<Box<dyn Notifier>> = vec![Box::new(Email), Box::new(Sms)];

And the compiler stops you cold:

text — terminal
error[E0038]: the trait `Notifier` cannot be made into an object
  --> src/main.rs:14:32
   |
14 | let notifiers: Vec<Box<dyn Notifier>> = vec![Box::new(Email), Box::new(Sms)];
   |                        ^^^^^^^^^^^^^ `Notifier` cannot be made into an object
   |
note: for a trait to be "object safe" it needs to allow building a vtable to
      allow the call to be resolvable dynamically
  --> src/main.rs:1:7
   |
1  | trait Notifier {
   |       -------- this trait cannot be made into an object...
2  |     async fn send(&self, msg: &str) -> Result<(), Error>;
   |              ^^^^ ...because method `send` is `async`

If you've written Rust for a while, this is jarring, because the exact same pattern works fine with sync traits. Box<dyn Notifier> for a sync trait just works. Add async to one method and the whole thing falls apart.

What's Actually Stable in Async Rust Right Now

As of early 2026, async fn in traits is genuinely solid for the case it was built for: static dispatch. If the compiler knows the concrete type at the call site, impl Notifier for Email and calling .send() on a concrete Email value works exactly like you'd expect. No heap allocation, no boxing, no runtime indirection. The compiler monomorphizes the call the same way it would for a generic function.

This covers most application code. Generic functions bounded by a trait (fn broadcast<N: Notifier>(n: &N, msg: &str)), a struct that holds a single known implementor, a builder pattern where the concrete type flows through, all of that works today with zero ceremony.

What it doesn't give you is dyn Notifier. And that's not a rough edge waiting to get smoothed over in a future release. It's a direct consequence of how async functions actually compile down, which means it's not going away without a much larger change to how trait objects work in the language.

Why Object Safety Is Still Broken for Async

An async fn doesn't return its result directly. It desugars into a state machine that implements Future, and that state machine's concrete type is anonymous, unique to that specific function body, and different in size for every implementor. Email::send and Sms::send produce two different, differently-sized future types even though they share a trait method signature.

For dyn Trait to work at all, the compiler needs to build a vtable: a fixed-size table of function pointers with a known, uniform layout for every method, shared across every type that could ever implement the trait. A method that returns "some future type, size determined per-implementor at monomorphization time" can't have a single entry in that table, because the table's whole job is to erase the concrete type and the future's size is part of what makes it concrete.

This is the same unnameable-type problem that made impl Trait in return position awkward for years, showing up again in a spot where object safety actually depends on it. It's not a missing feature so much as a structural mismatch: trait objects erase types to a fixed pointer-sized handle, and async fn return types don't have a fixed size to erase to.

ℹ This isn't an oversight

The Rust compiler team has been explicit that closing this gap requires either a way to name the anonymous future type, or an object-safety mechanism that doesn't rely on a fixed vtable layout. Both are active research directions, not near-term shipping features. Plan around the current limitation rather than waiting it out.

Workaround 1: Enum Dispatch

If your set of implementors is closed, meaning you're not expecting third-party code to add new ones, an enum sidesteps the object-safety question entirely because there's no trait object involved:

rust — notifier.rs
enum AnyNotifier {
    Email(Email),
    Sms(Sms),
}

impl AnyNotifier {
    async fn send(&self, msg: &str) -> Result<(), Error> {
        match self {
            AnyNotifier::Email(e) => e.send(msg).await,
            AnyNotifier::Sms(s) => s.send(msg).await,
        }
    }
}

let notifiers: Vec<AnyNotifier> = vec![
    AnyNotifier::Email(Email),
    AnyNotifier::Sms(Sms),
];

This isn't a workaround in the hacky sense, it's usually the better design anyway when the variant set is fixed. Every implementor's async method still gets compiled with full static dispatch inside the match, so there's no boxing and no vtable indirection. The compiler can inline aggressively because it knows the exact set of possibilities at compile time.

The main cost is boilerplate: every new implementor needs a new enum variant and a new match arm. The enum_dispatch crate automates that boilerplate if you have more than two or three variants, generating the enum and the forwarding methods from your existing trait and impl blocks.

Workaround 2: The async-trait Crate

If you're building something like a plugin system, where third-party crates need to implement your trait and you can't enumerate the concrete types ahead of time, enum dispatch isn't an option. You still need real dyn dispatch, and the async-trait crate is still the right tool for that in 2026:

rust — notifier.rs
use async_trait::async_trait;

#[async_trait]
trait Notifier {
    async fn send(&self, msg: &str) -> Result<(), Error>;
}

#[async_trait]
impl Notifier for Email {
    async fn send(&self, msg: &str) -> Result<(), Error> { /* ... */ Ok(()) }
}

let notifiers: Vec<Box<dyn Notifier>> = vec![Box::new(Email), Box::new(Sms)];

Under the hood, the macro rewrites your async method to return Pin<Box<dyn Future<Output = ...> + Send + '_>>, which is a real, nameable, fixed-size type (a boxed pointer) that fits in a vtable slot. That's the whole trick: box the future so its size becomes uniform, at the cost of one heap allocation per call.

⚠ Watch the Send bound

By default, async-trait adds a Send bound to the generated future. If any implementor's async body holds something non-Send across an .await point (a Rc, a non-Send guard), it won't compile. Use #[async_trait(?Send)] for single-threaded executors, but don't reach for it just to silence an error without checking whether your runtime actually needs Send futures.

For most applications, one heap allocation per call is genuinely irrelevant next to the I/O the method is about to do anyway. A network call or a database query dwarfs the cost of a single Box::pin. It only starts to matter on a hot path called millions of times a second with no I/O in between, which is a narrower case than most people assume when they first hit the object-safety error.

Workaround 3: RPITIT With an Object-Safe Adapter

If you're writing a library and want callers who don't need dyn dispatch to pay zero cost, while still supporting dyn dispatch for callers who do, you can define two traits: the real one using native async fn in traits for static dispatch, and a companion object-safe trait that boxes the future. A blanket impl bridges them automatically:

rust — notifier.rs
use std::future::Future;
use std::pin::Pin;

// Fast path: static dispatch via async fn in traits, zero-cost, no boxing.
trait Notifier {
    fn send(&self, msg: &str) -> impl Future<Output = Result<(), Error>> + Send;
}

// Object-safe companion trait: every method returns a boxed future instead.
trait DynNotifier: Send + Sync {
    fn send<'a>(
        &'a self,
        msg: &'a str,
    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>>;
}

// Blanket impl: any type implementing the fast trait gets the
// object-safe one for free, no extra code per implementor.
impl<T: Notifier + Send + Sync> DynNotifier for T {
    fn send<'a>(
        &'a self,
        msg: &'a str,
    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
        Box::pin(Notifier::send(self, msg))
    }
}

// Callers who know the concrete type use Notifier directly: zero-cost.
// Callers who need dyn dispatch go through DynNotifier: one allocation.
let notifiers: Vec<Box<dyn DynNotifier>> = vec![Box::new(Email), Box::new(Sms)];

This is essentially what the async-trait macro generates for you automatically, just written by hand and split into two traits instead of one. The payoff is that static callers, the ones calling Notifier::send directly on a concrete type, never touch the boxed path at all and get the same zero-cost dispatch as workaround 1. Only callers who actually reach for Box<dyn DynNotifier> pay the allocation.

This is genuinely more code to write and maintain: two trait definitions instead of one, plus a blanket impl to keep in sync if the signature ever changes. It's usually only worth it if you're building infrastructure other people build on top of (a runtime, a driver, a framework trait), where you don't get to decide whether your downstream users need static or dynamic dispatch and don't want to force the boxing cost on the ones who don't.

The Three Workarounds Side by Side

ApproachRuntime costWorks with third-party typesBest for
Enum dispatchNone (static dispatch inside a match)No, implementors must be listed in the enumClosed sets you control, plugin-free application code
async-trait crateOne heap allocation per callYes, any implementor can opt inPlugin systems, DI containers, open-ended dyn dispatch
RPITIT + object-safe adapterZero for static callers, boxed only for dyn callersYes, via the blanket implLibrary authors who need both dispatch modes

Which One Should You Actually Pick

If your implementors are a closed, known set, reach for an enum first. It's the option most people should default to and most people skip, because Box<dyn> is muscle memory carried over from sync Rust. If you need true open dispatch for plugins or dependency injection, use the async-trait crate and don't feel bad about the allocation, it's rarely the actual bottleneck once real I/O is involved. Only reach for a hand-split RPITIT plus adapter trait if you're writing a library where the allocation cost genuinely matters to your users, and you're willing to own the extra maintenance surface that comes with it.

Your situationReach for
You control every implementor and the set won't growEnum dispatch (or `enum_dispatch` for more than a few variants)
Third-party crates need to implement your trait (plugins, DI)The `async-trait` crate
You're writing a library and static-dispatch users shouldn't pay for boxingRPITIT with an object-safe adapter trait
You only ever call through a concrete type, no dyn anywherePlain `async fn in traits`, no workaround needed
You're migrating an existing sync `dyn Trait` to async under a deadlineStart with `async-trait` for correctness, optimize later once you've profiled it

The mistake to avoid is picking the workaround based on which one you've heard of first. async-trait is the most popular because it's the closest drop-in replacement for the sync dyn Trait pattern everyone already knows, but that popularity doesn't mean it's the right default. If you've dealt with a similar closed-set-versus-open-set tradeoff modeling data with a fixed set of shapes, it's worth comparing this to how C# handles closed variant sets with union types instead of an open class hierarchy, the underlying tradeoff (exhaustiveness and zero-cost dispatch versus open extensibility) is the same one showing up in a different language.

And if you're the kind of person who ends up debugging a service that's silently misbehaving because of a subtle async runtime issue, the discipline of reasoning carefully about what a compiler or runtime can and can't prove about your code applies well beyond object safety. It's the same category of "the tooling can now prove something it previously could only guess at" shift covered in how Go 1.26's goroutine leak profiler proves a goroutine is stuck instead of guessing. Object safety and provable liveness are different problems, but both come down to what the compiler or runtime can guarantee about a dynamically dispatched, asynchronous piece of code.

If you want a refresher on why vtables and dynamic dispatch have these constraints in the first place, before you decide whether a given workaround is worth the extra code, it's worth revisiting how dynamic dispatch and polymorphism work at the object level, the fixed-layout-vtable constraint that blocks dyn Notifier here is the same one that shapes virtual method tables in any object-oriented language.

Frequently Asked Questions

What does "the trait cannot be made into an object" mean in Rust?

It means the trait fails Rust's object-safety rules, so the compiler can't build a dyn Trait vtable for it. A vtable needs a fixed-size, uniform entry for every method across every possible implementor. Async methods break that requirement because each implementor's async method desugars into a differently-sized, anonymous future type, and there's no single fixed-size slot the vtable can point to.

  • Common triggers — async methods, methods returning impl Trait, generic methods, and methods taking Self by value all break object safety
  • Not a bug — it's Rust enforcing a real constraint on how trait objects are represented in memory
  • Fix, not workaround — moving the async method to a separate, non-object-safe trait (or using one of the three approaches in this article) resolves it
Does the async-trait crate replace native async fn in traits?
Native async fn in traitsasync-trait crate
DispatchStatic onlyStatic and dynamic (`dyn Trait`)
Allocation per callNoneOne `Box::pin` per call
Object-safe (`dyn`-compatible)NoYes
Requires a macroNo, built into the languageYes, `#[async_trait]`

No, they solve different problems. Use native async fn in traits whenever you only need static dispatch, it's faster and needs no macro. Reach for async-trait specifically when you need dyn Trait, which native async traits still can't provide. Many codebases use both: native traits for internal, statically-dispatched code, and async-trait at the boundary where a plugin or dependency-injected type needs dynamic dispatch.

How much does the async-trait crate's boxing actually cost?

One heap allocation and one deallocation per call, plus the indirection of an extra pointer dereference to poll the boxed future. In absolute terms that's on the order of tens of nanoseconds on modern hardware, allocator-dependent.

  • Usually irrelevant — if the method does any real I/O (network, disk, database), that I/O costs orders of magnitude more than the allocation
  • Starts to matter — on a hot path called millions of times per second with no I/O in between, where allocator contention becomes measurable
  • How to check — profile with a tool like cargo flamegraph before optimizing away the boxing; don't assume it's the bottleneck without measuring
Will Rust ever make async traits fully object-safe?

Possibly, but not on any near-term roadmap. It would require either a way to name the anonymous future type that async methods generate, or a fundamentally different object-safety mechanism that doesn't depend on a fixed-layout vtable. Both are active areas of language research, not features with a target release. Treat the current three-workaround landscape as the stable reality for the foreseeable future rather than a gap you can wait out.

How do I migrate an existing sync dyn Trait to async without breaking every caller?

Add #[async_trait] to both the trait definition and every impl block, then change each method signature to async fn. This keeps the trait object-safe throughout the migration, so Vec<Box<dyn Notifier>> call sites don't need to change at all, only the method bodies do:

rust — notifier.rs
#[async_trait]
trait Notifier {
    // was: fn send(&self, msg: &str) -> Result<(), Error>;
    async fn send(&self, msg: &str) -> Result<(), Error>;
}

💡 Tip

Migrate one implementor at a time behind the same trait if the codebase is large. Since async-trait preserves object safety, the trait object type never changes shape, so partially migrated implementors and already-migrated ones can sit in the same Vec<Box<dyn Notifier>> during the transition.

Can I mix enum dispatch and dyn dispatch for the same trait in one codebase?

Yes, and it's a reasonable pattern when most implementors are known but you also need to support one open-ended case, like a plugin. Define the enum for your known, closed set of variants, then add a Plugin(Box<dyn Notifier>) variant that falls back to dynamic dispatch for anything that doesn't fit the closed set:

rust — notifier.rs
enum AnyNotifier {
    Email(Email),
    Sms(Sms),
    Plugin(Box<dyn Notifier + Send + Sync>),
}

This gets you zero-cost dispatch for the common, known cases and pays the boxing cost only for the genuinely open-ended one. It requires the trait itself to still be object-safe for that one variant, so the Plugin variant's trait needs #[async_trait] (or the RPITIT adapter pattern) even if Email and Sms don't use it directly.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

dotnet

C# Union Types: Migrating from OneOf in .NET 11 (2026 Guide)

C# 15 native union types just shipped in .NET 11 preview. Here is how they compare to the OneOf library, what migration actually looks like, and the boxing tradeoff to know about.

Jun 23, 2026·8 min read
devops

Go 1.26 Goroutine Leak Detection: A Practical Guide

Go 1.26 ships a real goroutine leak profiler. Here's how to enable it, read the profile output, and catch leaks in production before OOMKilled does it for you.

Jul 18, 2026·11 min read

On this page

  • What's Actually Stable in Async Rust Right Now
  • Why Object Safety Is Still Broken for Async
  • Workaround 1: Enum Dispatch
  • Workaround 2: The async-trait Crate
  • Workaround 3: RPITIT With an Object-Safe Adapter
  • The Three Workarounds Side by Side
  • Which One Should You Actually Pick
  • Frequently Asked Questions