AI authored to showcase ironpad capabilities.
On December 28, 2023, Rust 1.75 shipped async fn in traits. Seven years of "we cannot express this yet" ended in one release, and the headline example was exactly what people had been asking for:
trait DataSource {
async fn fetch(&self) -> String;
}
It compiles. It works. It even inlines. There is just one line further down the release notes that most of us skimmed past: this does not work behind dyn. You cannot store an async fn trait as a Box<dyn Trait>, and two and a half years later you still cannot.
This notebook is the story of that asterisk: why it is there, the crate that routes around it, and the stabilization that was supposed to finish the job and instead closed unmerged in December 2025. Run the cells top to bottom. The third one fails on purpose.
dyn cannot hold an async fnA dyn Trait is a fat pointer: one half points at the data, the other half points at a vtable, a small table of function pointers with fixed, known signatures. To build that table, the compiler must know the exact return type of every method, right down to its size.
That is precisely what async fn will not tell it. An async fn does not return a String; it returns an anonymous impl Future<Output = String>, a compiler-generated state machine whose type has no name you can write and whose size depends on the concrete implementor. Clock produces one future type, a database client produces a different one, and the two are not the same size. No single function pointer fits the vtable slot.
So the language offers two paths: monomorphize (impl Trait, which bakes in the concrete type and inlines beautifully) or erase the type behind dyn (which needs a fixed size the future cannot promise). Rust 1.75 shipped the first path and left the second as an open problem. The next cell walks straight into it.
dyn actually demandsThe error you just hit is E0038. The compiler even names the concept for you: DataSource is not dyn compatible (the 2024 rename of what a decade of Rust developers learned as object safety). Dyn compatibility is a short list of rules, and every rule reduces to the same constraint: the vtable is built once, at compile time, and each slot holds exactly one function pointer with one fixed signature.
The list, compressed:
Self: Sized bound on the trait. A trait object is unsized by definition; requiring Sized is a contradiction in terms.&self, &mut self, Box<Self>, Rc<Self>, Arc<Self>, or Pin of those. The generated code has to find the data pointer somewhere predictable.fn parse<T: FromStr>(&self) would need one vtable slot per T, and the set of Ts is open. Which instantiation goes in the table?Self by value in signatures. The caller does not know the concrete size, so it cannot pass or return Self on the stack.impl Trait in return position. The return type is anonymous and different for every implementor.That last rule is the one async fn trips, because async fn is nothing but sugar for RPITIT, return-position impl Trait in traits:
// What you wrote:
trait DataSource {
async fn fetch(&self) -> String;
}
// What the compiler actually sees:
trait DataSource {
fn fetch(&self) -> impl Future<Output = String> + '_;
}
Clock::fetch returns one state machine type. A Postgres client's fetch returns a completely different one, plausibly 100x larger, because it holds connection state across its await points. A vtable slot needs one return type with one size and one ABI. There is no single size, so there is no slot, so there is no table. That is the entire asterisk, mechanically.
Two experiments make this concrete. First, proof that the vtable machinery itself is fine; sync traits dispatch dynamically all day without complaint. Second, a direct measurement of how badly futures violate the one-size rule.
The language cannot give you the fix yet, but a proc macro can. dynosaur (by Santiago Pastorino, alongside Niko Matsakis, as the lang team's blessed prototype) takes your async fn trait and generates a companion type, DynDataSource, that is dyn-compatible. It does the one thing the vtable needed: it boxes the future, turning that unnameable impl Future into a Pin<Box<dyn Future>> with a fixed size that fits neatly in a function-pointer slot.
If you remember #[async_trait], think of this as its successor with the sharp edge filed off. async_trait boxed every call, static or dynamic, because in 2019 that was the only trick going. dynosaur boxes only on the dyn path and leaves your monomorphized calls untouched and zero-cost. You pay for an allocation exactly when you opt into type erasure.
The rule the macro asks you to remember is small: anywhere you would have written dyn Trait, write DynTrait instead. Here is the declaration this notebook shares across its cells (the full source, with three implementors, lives in the shared panel):
#[dynosaur::dynosaur(pub DynDataSource = dyn(box) DataSource)]
pub trait DataSource {
fn name(&self) -> &str;
async fn fetch(&self) -> String;
}
Given your trait, dynosaur emits three things: a dyn-compatible erased twin of the trait, a blanket impl that adapts every real implementor to that twin, and a wrapper struct that implements your original trait by forwarding through the erased one. DynDataSource::new_box(value) is the constructor; because the wrapper implements your trait, it drops straight into a Box<DynDataSource>, a Vec of them, or a &mut DynDataSource argument.
Here is the expansion, simplified. The real output uses an unsized #[repr(transparent)] wrapper so &DynDataSource and Box<DynDataSource> avoid a double indirection, and it handles lifetimes and &mut receivers; this version keeps the idea and drops the pointer tricks:
// 1. The dyn-compatible twin: same methods, but the future is boxed,
// so every return type has one fixed size. A vtable slot fits.
trait ErasedDataSource {
fn name(&self) -> &str;
fn fetch<'a>(&'a self) -> Pin<Box<dyn Future<Output = String> + 'a>>;
}
// 2. Every DataSource is automatically an ErasedDataSource.
// Box::pin here is the single allocation this whole story is about.
impl<T: DataSource> ErasedDataSource for T {
fn name(&self) -> &str {
<Self as DataSource>::name(self)
}
fn fetch<'a>(&'a self) -> Pin<Box<dyn Future<Output = String> + 'a>> {
Box::pin(<Self as DataSource>::fetch(self))
}
}
// 3. The wrapper you actually touch. It IS a DataSource, so it drops into
// any API that wants one.
pub struct DynDataSource {
inner: Box<dyn ErasedDataSource>,
}
impl DynDataSource {
pub fn new_box(value: impl DataSource + 'static) -> Box<DynDataSource> {
Box::new(DynDataSource { inner: Box::new(value) })
}
}
impl DataSource for DynDataSource {
fn name(&self) -> &str {
self.inner.name()
}
async fn fetch(&self) -> String {
self.inner.fetch().await
}
}
The blanket impl is the load-bearing part. Box::pin(<Self as DataSource>::fetch(self)) takes the anonymous, implementor-specific future and moves it behind one pointer type, Pin<Box<dyn Future>>, that is the same size for everyone. That is the whole fix. Everything else is plumbing so you never have to look at it.
For performance, what matters is what dynosaur does not touch. Call the trait generically, with an impl DataSource argument or an <S: DataSource> bound, and there is no wrapper, no box, and no vtable in sight. The async fn inlines exactly as it did in Act 1. And because the expansion above is ordinary Rust, nothing stops you from building it yourself. The next cell does exactly that: the same trick, hand-rolled, no macro.
#[async_trait]: same box, different addressThe old workaround deserves a precise comparison, because "dynosaur is async_trait but better" hides the actual mechanism. Both crates end at the same place: a Pin<Box<dyn Future>> where an anonymous future used to be. The difference is where the allocation lives, and that placement decides who pays.
#[async_trait] rewrites the trait itself. After expansion there is no async fn left anywhere:
// #[async_trait] turns the trait into this. Every impl must return a
// boxed future, so every call site, static or dynamic, allocates:
trait DataSource {
fn fetch<'a>(&'a self) -> Pin<Box<dyn Future<Output = String> + Send + 'a>>
where
Self: 'a;
}
The box is baked into the trait's signature. A fully generic caller, fn read<S: DataSource>(s: &S), allocates on every call even though monomorphization knows the concrete type and could have inlined everything. You pay the dyn tax on the static path for every implementor. Note the + Send too: async_trait bolts it onto every future by default (there is a #[async_trait(?Send)] opt-out), which quietly forecloses single-threaded implementors.
dynosaur leaves your trait exactly as you wrote it and moves the box into the companion type. Your trait's fetch still returns the raw, unboxed, implementor-specific future; only DynDataSource's forwarding impl calls Box::pin. The allocation lives in the adapter, so you pay it precisely when you hold the adapter, and never when you hold the real type.
To be fair to 2019: the async_trait design was the only option then. The language could not express an async fn in a trait at all, so the crate had to replace the signature wholesale. Post-1.75, the trait can stay honest and the erasure can be opt-in. The next cell shows the receipts: the static path through our shared DataSource, with no box anywhere in sight.
Be precise about what the dyn path costs, because "it allocates" scares people out of proportion to the nanoseconds involved. Each call to fetch through DynDataSource costs three things: one heap allocation for the future, one indirect call through the vtable, and one deallocation when the future completes and drops. Plus the quiet fourth cost: an indirect call is an inlining barrier, so the optimizer cannot fuse the future's body into its caller the way it does on the static path you just ran.
Numbers, directional rather than precise: a small allocation on a warm modern allocator runs 20-40 ns, and the indirect call adds single-digit nanoseconds on a warm branch predictor. Now put that against the work the call actually does:
read called once per byte pays the box a million times per megabyte, roughly 30 ms of pure allocator overhead per MB, before counting the lost inlining. A memcpy moves the same megabyte in well under a millisecond.The fix for the hot-loop case is not "avoid dyn"; it is granularity. Box at the chunk level, not the item level: a buffered reader that fetches 8 KB per call amortizes one allocation across 8192 bytes, and the overhead drops by four orders of magnitude. For a mental model: the box is a stack frame that moved to the heap because the caller could not know its size at compile time. Pay that rent per connection or per batch, never per element.
Send bound that broke the stabilizationBoxing the future buys you dynamic dispatch. It does not buy you Send, and Send is the harder half of the story. A multithreaded executor like Tokio moves a task's future between worker threads, so it demands Fut: Send. But an async fn in a trait hides its future type, which leaves nowhere obvious to write that bound. The answer the language designed is Return Type Notation, which names the future just enough to constrain it:
fn spawn_all<T>(sources: Vec<T>)
where
T: DataSource<fetch(..): Send>, // the future fetch() returns must be Send
{ /* ... */ }
RTN has an accepted design in RFC 3654 and a working nightly implementation. Stabilization was tracked in rust-lang/rust#138424, and on December 27, 2025 that PR closed unmerged. The reason was not technical. Its author, Michael Goulet (compiler-errors), one of the most prolific contributors to the trait solver and async lowering, had stepped away from the project. The feature did not fail review. It lost its driver.
That is the uncomfortable reality under async Rust's hardest features: a very small number of people carry them, and when one leaves, a nearly-finished feature can simply stop. Today's workaround is trait-variant, which generates a parallel Send flavor of your trait so callers can ask for it by name; the next section takes that pattern apart.
Every cell above sidesteps Send entirely. ironpad compiles cells to wasm32-unknown-unknown, which is single-threaded, so nothing here ever crosses a thread boundary. The single hardest open problem in dyn async traits is the one this environment gets to quietly ignore. Convenient, yes, and also exactly why "just run it in wasm" is not an answer for the multithreaded servers that motivated the feature in the first place.
trait-variant: the companion pattern, applied to SendUntil RTN stabilizes (see above for how that is going), the officially recommended answer for the Send problem, straight from the 1.75 announcement, is trait-variant. One attribute:
#[trait_variant::make(SendDataSource: Send)]
pub trait DataSource {
fn name(&self) -> &str;
async fn fetch(&self) -> String;
}
expands into two traits. Yours, untouched. And a second one where the sugar is unrolled with Send bolted on:
pub trait SendDataSource: Send {
fn name(&self) -> &str;
fn fetch(&self) -> impl Future<Output = String> + Send;
}
plus a blanket impl bridging back, so anything implementing the Send variant works where the plain trait is expected. Callers now choose by name: a Tokio spawn path bounds on T: SendDataSource; a single-threaded path takes T: DataSource and stays open to !Send implementors like Rc-holding caches.
This is the same move dynosaur makes. The language cannot yet express a constraint on a hidden type, so a macro mints a second, more explicit item and lets callers pick: dynosaur mints a companion type for the dyn hole, trait-variant mints a companion trait for the Send hole. Both are maintained under the same rust-lang umbrella that is designing the real fixes, which is the tell: these are acknowledged polyfills over known gaps, not community hacks the lang team wishes would go away.
The trade-off is API surface. Your one concept now exports two trait names, rustdoc shows both, and downstream code has to know which one to bound on. Livable, but it is exactly the friction RTN exists to remove: T: DataSource<fetch(..): Send> says the same thing with zero extra items.
Worth seeing in one place, because the pacing makes the point:
async-trait. The ecosystem needed async traits so badly that the workaround shipped before the language feature it works around.async/await. Traits are excluded from day one; the desugaring produces an anonymous type the trait system cannot yet hold.async fn in traits, static dispatch only, dyn explicitly deferred.trait-variant for the Send gap.Four years from the first workaround to a partial stabilization. Two and a half more, and counting, for the second half of the feature. The takeaway: in Rust, "stable" is a frontier, and the frontier moves at the speed of the two or three people pushing on it.
So where does that leave native dyn async traits, the real fix that would make this whole notebook unnecessary? Roughly here:
async fn in traits: stable since 1.75, static dispatch only. Solid.dyn async traits: still unsolved in the language itself. dynosaur is the blessed prototype and the de facto answer, sitting at v0.3 and aiming at a 1.0 that doubles as the design's proof of concept.dyn*, the experimental primitive meant to make boxed dispatch a first-class language feature: quiet. Niko's "Dyn you have an idea for dyn?" frames it as still needing design work, not implementation muscle.Send story: nightly-only, and without its driver as of December 2025.Two and a half years after the fanfare, the asterisk is still an asterisk. The consolation is that the workaround is good: dynosaur hands you the flexibility of a trait object and keeps the zero-cost static path, which is more than #[async_trait] ever managed. The rest of the story is a reminder that "shipped" and "finished" are different words.
Further reading
dynosaur source and examples