How to use async closures

Define an async closure with the async keyword and await its result to handle non-blocking operations like fetching web page titles.

The async closure gap

You are building a retry loop. You want to pass the network call as a callback. You type async || { fetch_data().await } and hand it to your function. The compiler rejects you with a hard stop. Async closures are unstable. You are stuck staring at a syntax that looks perfectly reasonable but refuses to compile.

This friction trips up almost everyone coming from JavaScript or Python. In those languages, async is a simple modifier you slap on any function or lambda. Rust treats async differently. It is a block that transforms the code inside into a state machine. That transformation follows strict rules that clash with how closures capture variables.

Rust does not have stable async closures. You cannot write async || {} in production code today. Instead, you write a closure that returns a Future. The closure runs synchronously to set up the work and returns a ticket you can await. This pattern looks verbose at first. It gives you explicit control over captures, lifetimes, and Send bounds. The compiler forces you to be precise about what moves where and when.

Accept the two-step pattern. It is the foundation of every async callback in the ecosystem.

Closures versus futures

A closure is a chunk of code that captures variables from its environment. When you call a closure, it runs immediately. A future is a value that represents work in progress. When you await a future, you drive the work forward until it completes.

Think of a closure as a recipe card. It contains ingredients and instructions. When you hand the card to a chef, they cook the meal right there. An async closure would be a recipe card that contains a meal kit. You hand the card to the kitchen. The kitchen opens it, sees the kit, and hands you a numbered ticket. You wait for the ticket. The recipe card is not the ticket. The recipe card produces the ticket.

Rust models this by writing a closure that returns a future. The closure is the recipe card. The future is the ticket. You call the closure to get the ticket, then you await the ticket to get the meal.

Here is the smallest case: a string reference, a closure, and an async block.

/// A closure that returns a future.
/// The closure itself runs synchronously to construct the future.
let fetch_title = |url: &str| async {
    // The async block captures url by reference.
    // It builds a state machine that will fetch the title when polled.
    let text = trpl::get(url).await.text().await;
    trpl::Html::parse(&text)
        .select_first("title")
        .map(|t| t.inner_html())
};

// Call the closure to get the Future.
// Then await the Future to execute the async logic.
let result = fetch_title("https://example.com").await;

The closure runs instantly. It checks the arguments, captures the environment, and builds the future. The future contains the actual async logic. You await the future to execute the logic. This separation matters when you deal with lifetimes and task spawning.

Trust the separation. The closure sets up the future. The future does the work.

How the compiler handles it

The language team has worked on async closures for years. The feature sits behind #![feature(async_closure)]. The difficulty is semantic.

Closures capture variables by reference by default. If you write |x| x + 1, the closure borrows x. Async blocks capture variables by move. If you write async { x + 1 }, the future takes ownership of x. An async closure would need to merge these behaviors. The compiler cannot guess whether you want to borrow or move without explicit syntax.

There is also the type system. The type of a closure is opaque. The type of a future is opaque. Combining them creates a type that is hard to express in the trait system. The community debates whether to use Fn traits with associated types or a new trait hierarchy.

Until the design stabilizes, you use the workaround. The workaround is not a hack. It is the explicit form of what async closures will likely become. You write the closure, you return the future, you await the future.

Convention aside: the community calls this pattern "closure returning a future." You will see it in every async crate. tokio::spawn takes a closure that returns a future. futures::stream::iter takes a closure that returns a future. Learn the pattern. It is the standard.

Realistic example: a retry wrapper

You want a function that retries an async operation. The operation might fail. You pass the operation as a closure. The closure needs to return a future. The future needs to resolve to a result.

Here is a generic retry function. It takes a closure that returns a future. It calls the closure, awaits the future, and checks the result. If the result is an error, it retries.

use std::future::Future;

/// Retries an async operation up to `max_attempts` times.
/// The closure `op` must return a Future that resolves to a Result.
async fn retry<F, Fut, T, E>(op: F, max_attempts: u32) -> Result<T, E>
where
    F: Fn() -> Fut,
    Fut: Future<Output = Result<T, E>>,
{
    let mut last_err = None;

    for _ in 0..max_attempts {
        // Call the closure to get the future.
        // The closure captures the environment each time it runs.
        let result = op().await;

        match result {
            Ok(value) => return Ok(value),
            Err(e) => {
                last_err = Some(e);
                // Retry logic continues on the next loop iteration.
            }
        }
    }

    // Return the last error if all attempts failed.
    Err(last_err.unwrap())
}

You use this function by passing a closure. The closure captures the variables it needs. The closure returns an async block.

/// Fetches data with retry logic.
async fn fetch_with_retry(url: &str) -> Result<String, String> {
    // The closure captures url by reference.
    // It returns a future that fetches the data.
    let fetch_op = || async {
        // Simulate a fetch that might fail.
        let response = trpl::get(url).await;
        response.text().await
    };

    retry(fetch_op, 3).await
}

The closure fetch_op is called three times. Each call creates a new future. The future captures url by reference. The reference is valid because url lives longer than the retry loop. This works because the closure borrows url, and url is still in scope.

If you need to spawn the retry loop on a separate task, you hit a wall. The closure borrows url. The future borrows url. The task needs to own the data. You must use move.

/// Fetches data with retry logic, spawning on a new task.
async fn fetch_with_retry_spawn(url: String) -> Result<String, String> {
    // Clone url to move it into the closure.
    let url_owned = url;

    let fetch_op = move || {
        // Move url_owned into the async block.
        let url = url_owned.clone();
        async move {
            let response = trpl::get(&url).await;
            response.text().await
        }
    };

    // Spawn requires Send bounds.
    // The closure and future must be Send.
    tokio::spawn(async move {
        retry(fetch_op, 3).await
    })
    .await
    .unwrap()
}

The move keyword forces the closure to take ownership of url_owned. The async block inside takes ownership of url. The future owns the data. The task can run on any thread.

Convention aside: use async move inside closures when you spawn tasks. The community calls this the "move everything" rule for spawned futures. It avoids lifetime errors and Send violations. If you borrow, you tie the future to the stack frame. You cannot spawn it.

Make the future own its data before you hand it to the runtime.

Pitfalls and compiler errors

The closure-returning-future pattern exposes lifetime and bound issues that async fn hides. You will see these errors.

E0373 — closure may outlive the current function, but it borrows x

This error happens when you pass a closure to a function that stores it or spawns it, but the closure borrows a local variable. The closure outlives the variable. The compiler rejects the code.

async fn bad_spawn() {
    let data = String::from("hello");

    // Closure borrows data.
    let task = || async {
        println!("{}", data);
    };

    // Error: E0373.
    // The closure borrows data, but spawn requires the closure to be 'static.
    tokio::spawn(async move {
        task().await
    });
}

Fix it by moving the data into the closure. Use move on the closure. Clone the data if needed.

async fn good_spawn() {
    let data = String::from("hello");

    // Closure moves data.
    let task = move || {
        let data = data.clone();
        async move {
            println!("{}", data);
        }
    };

    // OK. The closure owns the data.
    tokio::spawn(async move {
        task().await
    });
}

E0277 — trait bound not satisfied

This error happens when the future is not Send. The future captures a variable that is not Send. Common culprits are Rc, RefCell, or raw pointers.

use std::rc::Rc;

async fn bad_send() {
    let data = Rc::new(String::from("hello"));

    let task = || async {
        // Future captures Rc.
        println!("{}", data);
    };

    // Error: E0277.
    // The future is not Send because it captures Rc.
    tokio::spawn(async move {
        task().await
    });
}

Fix it by using Arc instead of Rc. Arc is thread-safe and implements Send.

E0759 — has a larger lifetime than the borrowed content

This error happens when the future borrows a variable, but the variable is dropped before the future completes. The future holds a dangling reference.

async fn bad_lifetime() {
    let data = {
        String::from("hello")
    };

    // Error: E0759.
    // data is dropped at the end of the block.
    // The future borrows data, so it cannot outlive the block.
    let _future = async {
        println!("{}", data);
    };
}

Fix it by extending the lifetime of the variable. Move the variable out of the block. Or use move to take ownership.

The async closure syntax trap

Attempting async || {} on stable Rust triggers a hard rejection. The compiler tells you async closures are unstable. You cannot use this syntax in production. Use the closure-returning-future pattern instead.

If you need async closures for a prototype, you can enable the feature flag #![feature(async_closure)] on nightly. Do not do this in production code. The API will change. Your code will break.

Treat the compiler errors as proof. If the compiler rejects the lifetime, the lifetime is wrong. If the compiler rejects the bound, the bound is missing. Fix the code. Do not fight the checker.

Decision matrix

Use |args| async { ... } when you need a callable that returns a future. This is the standard pattern for async callbacks. Use it for retry loops, parallel maps, and task factories. The closure captures the environment. The future does the work.

Use async fn when you do not need to pass the function as a value. Async functions are easier to read and write. They handle captures automatically. Use them for top-level logic and library APIs.

Use async-trait when you need async methods in a trait object. The async_fn_in_trait feature is unstable. The async-trait macro expands async methods into methods returning Box<dyn Future>. Use it for plugin systems and dynamic dispatch.

Use Box<dyn Future<Output = T>> when you must erase the type. Store futures in a Vec or return them from a function with a fixed signature. This adds heap allocation and indirection. Use it only when generics are not an option.

Use async move inside closures when you spawn tasks. The future must own its data. Borrowing ties the future to the stack. Moving breaks the tie.

Pick the generic closure. Box only when you have to.

Where to go next