๐ŸŽ“ A1Learner
๐Ÿ”

Rust Async Recursion

Async recursion is an async function that calls itself. An async fn compiles into a state machine with a fixed size.

The direct way does not compile

This looks exactly like sync recursion โ€” and fails. The error text below is real rustc output (E0733), captured during this course's research.

async fn countdown(n: u32) {
    if n > 0 {
        countdown(n - 1).await; // error[E0733]: recursion in an
                                // async fn requires boxing
    }
}

Box::pin breaks the circle

One change: box the recursive call. The future type is now finite. Each level stores a pointer to the next level. It does not store the whole next level.

async fn countdown(n: u32) {
    if n == 0 {
        println!("liftoff!");
    } else {
        println!("T-minus {n}");
        Box::pin(countdown(n - 1)).await;
    }
}

It works with values too

Tree recursion works in sync code. The async version changes nothing except the Box::pin around the recursive call. With Node { size: 1, children: [2, 3 -> [4]] } this returns 10.

async fn sum_tree(node: &Node) -> u64 {
    let mut total = node.size;
    for child in &node.children {
        total += Box::pin(sum_tree(child)).await;
    }
    total
}

Example

async fn countdown(n: u32) {
    if n == 0 {
        println!("liftoff!");
    } else {
        println!("T-minus {n}");
        Box::pin(countdown(n - 1)).await;
    }
}

#[tokio::main]
async fn main() {
    countdown(3).await;
}

Key points

  • A future's size must be known at compile time.
  • A recursive call would put the future inside itself, inside itself... an infinitely sized type.
  • The compiler refuses with error E0733: 'recursion in an async fn requires boxing'.
  • The fix: wrap only the recursive call in Box::pin โ€” Box::pin(countdown(n - 1)).await.
  • The depth now lives on the heap.
  • The future itself stays pointer-sized at each level.
  • The async fn keeps its natural signature.
  • Direct Box::pin in the async fn works on modern Rust.
  • Very old code wrapped the whole body in a non-async fn.
  • That fn returned Pin<Box<dyn Future>>.
  • You may still meet that shape.
  • Every level allocates one Box.
  • Tree-shaped recursion (filesystems, JSON, DOMs) is shallow.
  • The cost is trivial.
  • A million-deep chain pays a million boxes.
  • If the recursive future must erase its type and be Send, name it explicitly.
  • Send is what spawning needs.
  • Write fn walk(...) -> Pin<Box<dyn Future<Output = T> + Send + 'a>>.
  • The box is the same idea, with the type written out.

Each level awaits a boxed copy of the level below it. The Box moves that level's state machine onto the heap. So every future is small and fixed-size. The call chain can still be as deep as your data needs.

Try it Yourself

Edit the code, then press Run.

Running is simulated ยท Rust Playground

Exercise

Why does a directly recursive async fn fail to compile?