Skip to content
rusthacks.dev
Concurrency

Pin Box and Dyn Future in Rust and Why All Three Appear

Pin<Box<dyn Future<Output = T>>> looks like noise until each word has a job. Learn why a future must be pinned, why it is boxed, why it is dyn, and how to write it with less ceremony.

7 min read
Summarize in AI
Flat illustration for the post “Pin Box and Dyn Future in Rust and Why All Three Appear”, composed around traits.

Pin<Box<dyn Future<Output = T> + Send>> looks like a ritual, and it is one with a reason for every part. It is the type you write when a future's concrete type has to be erased: a recursive async function, a Vec of futures that came from different async blocks, a trait method that returns a future, a struct field holding "some future". This post takes the type apart piece by piece, shows the five situations where you need it with compiling code, the error messages that tell you that you do, what the box costs, and the shorthand (BoxFuture, Box::pin, pin!) that makes it readable. Everything compiled on Rust 1.99, edition 2024, with tokio 1 and futures 0.3.

Reading the type from the inside out

PieceWhy it is there
Future<Output = T>What the caller needs: something to .await that yields a T
dynThe concrete type is erased; every async block has its own unnameable type, and this is how different ones share a field or a vector
+ SendSo the future can be moved between threads by a multi-thread runtime; omit it for single-thread-only futures
BoxA dyn value has no size known at compile time, so it must sit behind a pointer (the size error)
PinA future may contain references into itself once polled, so it must not move; Pin promises it will not
+ 'a (implicit 'static)How long the borrowed data inside the future must live; write it when the future borrows

The futures crate names the common case: futures::future::BoxFuture<'a, T> is Pin<Box<dyn Future<Output = T> + Send + 'a>>, and LocalBoxFuture is the same without Send. Use the alias in signatures. Create the value with Box::pin(fut) or the method .boxed() from FutureExt.

Situation 1: recursion

An async function that calls itself would contain itself in its own state, an infinitely large type. The compiler refuses, and the fix is to box the recursive call so the state holds a pointer:

rust
async fn depth(n: u32) -> u32 {
    if n == 0 { 0 } else { 1 + depth(n - 1).await }
}

fn main() {}
Text
error[E0733]: recursion in an async fn requires boxing
 --> src/main.rs:1:1
  |
1 | async fn depth(n: u32) -> u32 {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2 |     if n == 0 { 0 } else { 1 + depth(n - 1).await }
  |                                ------------------ recursive call here
  |
  = note: a recursive `async fn` call must introduce indirection such as `Box::pin` to avoid an infinitely sized future

The message names the cure: "recursion in an async fn requires boxing". Applied:

rust
use futures::future::{BoxFuture, FutureExt};

fn depth(n: u32) -> BoxFuture<'static, u32> {
    async move {
        if n == 0 {
            0
        } else {
            1 + depth(n - 1).await
        }
    }
    .boxed()
}

fn sum_tree(values: Vec<Vec<u32>>) -> BoxFuture<'static, u32> {
    async move {
        let mut total = 0;
        for branch in values {
            total += branch.iter().sum::<u32>();
            tokio::task::yield_now().await;
        }
        total
    }
    .boxed()
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    println!("{}", depth(5).await);
    println!("{}", sum_tree(vec![vec![1, 2], vec![3], vec![4, 5, 6]]).await);
}
Text
5
21

Each level of recursion allocates one box, so a recursion of depth 1,000 makes 1,000 allocations, and each future is small. In stable Rust 1.77 and later, you can also keep the async fn syntax and box only the call: Box::pin(depth(n - 1)).await inside the function. The explicit-signature form above is clearer for public functions.

Situation 2: futures from different async blocks in one collection

Two async blocks have two different types even when they look identical, so a vector of futures needs erasure:

rust
use futures::future::{join_all, BoxFuture, FutureExt};
use tokio::time::{sleep, Duration};

fn steps(verbose: bool) -> Vec<BoxFuture<'static, String>> {
    let mut v: Vec<BoxFuture<'static, String>> = Vec::new();
    v.push(async { "load config".to_string() }.boxed());
    v.push(
        async {
            sleep(Duration::from_millis(5)).await;
            "warm cache".to_string()
        }
        .boxed(),
    );
    if verbose {
        v.push(Box::pin(async { "print banner".to_string() }));
    }
    v
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    println!("{:?}", join_all(steps(false)).await);
    println!("{:?}", join_all(steps(true)).await);
}
Text
["load config", "warm cache"]
["load config", "warm cache", "print banner"]

Without the type annotation and the boxing, the second push does not compile, because every async block has its own type and a vector holds one. This is the same situation as returning two different closures, described in returning closures.

Situation 3: a future in a struct field

A struct that stores "the current operation" has to name its type. When you cannot, or when the operation changes over time, erase it:

rust
use futures::future::{BoxFuture, FutureExt};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::time::{sleep, Duration};

struct Retry {
    attempt: u32,
    current: BoxFuture<'static, Result<u32, String>>,
}

fn try_once(attempt: u32) -> BoxFuture<'static, Result<u32, String>> {
    async move {
        sleep(Duration::from_millis(10)).await;
        if attempt < 3 { Err(format!("attempt {attempt} failed")) } else { Ok(attempt * 100) }
    }
    .boxed()
}

impl Future for Retry {
    type Output = Result<u32, String>;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        loop {
            match self.current.as_mut().poll(cx) {
                Poll::Pending => return Poll::Pending,
                Poll::Ready(Ok(v)) => return Poll::Ready(Ok(v)),
                Poll::Ready(Err(e)) => {
                    println!("{e}, retrying");
                    self.attempt += 1;
                    self.current = try_once(self.attempt);
                }
            }
        }
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let r = Retry { attempt: 1, current: try_once(1) }.await;
    println!("{r:?}");
}
Text
attempt 1 failed, retrying
attempt 2 failed, retrying
Ok(300)

The field can be replaced with a different future on each retry, which an unboxed type could not do. The box is what lets Retry itself be Unpin, and with it simple to write: self.current.as_mut().poll(cx) works because a Pin<Box<_>> hands out a pinned mutable reference on demand.

Situation 4: trait methods returning futures

A trait that must be usable as a trait object returns a boxed future from its methods: the manual form of what the async-trait macro writes. The macro's expansion and costs are in the async-trait crate, and the native alternative with its limits is in async traits then and now.

Situation 5: callbacks that return futures

A stored callback that takes an argument and does async work has a type like Box<dyn Fn(Request) -> BoxFuture<'static, Response> + Send + Sync>. This is how routers and middleware stacks hold handlers: the outer box erases the closure, the inner one erases the future it returns. The arrangement, and its ergonomic cost, is why web frameworks wrap it in extractor machinery.

What the box costs, and when to avoid it

One heap allocation per future, plus an indirect call at each poll. For a future that lives for a network round trip, the allocation is invisible. For one created and polled millions of times per second, it is measurable, at the 25 nanosecond scale shown in the async-trait benchmark. Avoid boxing by default and add it where the compiler asks for it. Two ways to skip it: use generics (impl Future in argument position, a type parameter on the struct), and use join! or select! on a fixed number of futures, which need no erasure because each keeps its own type. A third option for the "stack" version is the pin! macro, which pins a future on the stack without allocating:

rust
use std::pin::pin;
use tokio::time::{sleep, timeout, Duration};

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let work = pin!(async {
        sleep(Duration::from_millis(300)).await;
        "slow work done"
    });

    let mut work = work;
    let first = timeout(Duration::from_millis(100), work.as_mut()).await;
    println!("first try: {first:?}");
    let second = timeout(Duration::from_millis(500), work.as_mut()).await;
    println!("second try: {second:?}");
}
Text
first try: Err(Elapsed(()))
second try: Ok("slow work done")

The same future was polled across two timeouts, because the first timeout dropped only a mutable reference to the pinned future, not the future itself. That trick (poll, give up, poll again) needs the pinned form, and it needs no allocation. Pinning on the stack is limited to the scope that owns the value; to return it or store it, box it.

A small readability habit pays off here: define a local alias, type Job = BoxFuture<'static, Result<(), Error>>;, and use it everywhere. The full type appears in signatures, struct fields, vectors and channel types, and written out each time it hides what the code is doing. With the alias, a function that returns Job reads as what it is: a unit of deferred work that can be queued, sent to another thread and awaited.

Why Pin exists at all

A future compiled from an async block can hold a reference to one of its own local variables across an await. That makes it a self-referential struct: moving it would leave the reference pointing at the old location. Pin is the type-system promise that the value will not move again, which lets the compiler generate such futures safely. Most code never touches Pin directly because .await, Box::pin and pin! handle it; the cases above are where it surfaces. The mechanism behind the self-reference is shown in futures as state machines.

Also inRust
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.