How to Use Async Closures in Rust

Define async closures in Rust by using the async keyword to enable non-blocking operations with .await inside the function body.

The syntax you want doesn't exist yet

You are building an event loop. You want to pass a handler to a function that will run later. You write async |event| { process(event).await }. The compiler rejects you. Rust does not support the async keyword on closures in stable releases.

This feels like a missing feature. Many languages let you mark a lambda as async. Rust's type system makes this harder than it looks. The language provides a complete workaround that works everywhere. You write a closure that returns a future. The syntax is |event| async { process(event).await }. The difference is subtle but important. The closure itself is synchronous. It runs instantly to produce a future. The future contains the async logic.

What an async closure actually is

A closure in Rust is a bundle of code plus captured variables. When you call a closure, it runs and returns a value. An async operation is different. It returns a Future. A future is a state machine that can be polled. It does the work over time.

If a closure were truly async, calling it would return a future. That part is straightforward. The hard part is the type. Closures have anonymous types. The compiler generates a unique type for each closure based on what it captures. If the closure is async, the type must also represent the state machine of the future. That state machine depends on the captured variables and the control flow inside the async block.

The resulting type is huge and complex. Rust needs to name this type so you can pass the closure around. Stable Rust does not yet have a way to express "a closure type that implements Fn and returns a future" without nightly features. The compiler team is working on async_closure, but it is not stable.

Until then, the standard pattern is a closure that returns a future. The closure type is simple. It captures variables and returns a future. The future type is anonymous, but you can hide it behind impl Future. This separation keeps the type system manageable.

The stable pattern: closure returning a future

The pattern is |args| async move { body }. The async keyword wraps the body in a future. The closure returns that future. The move keyword is almost always required. It forces the closure to take ownership of captured variables. The future needs to own the data it uses. If the closure borrows data, the future will hold a reference. That reference must stay valid for the entire lifetime of the future. That constraint makes async closures nearly impossible to use in practice.

use std::future::Future;

fn main() {
    // This closure returns a Future.
    // It takes a String by value.
    let handler = |msg: String| async move {
        // The async block creates a future.
        // move ensures the future owns msg.
        println!("Processing: {}", msg);
        msg.len()
    };

    // Calling the closure returns the future.
    // The closure body has not run yet.
    let fut: Future<Output = usize> = handler("hello".to_string());

    // You cannot await in a synchronous main.
    // In an async context, you would write `fut.await`.
}

The convention in the community is to write async move immediately after the pipe. It signals that the closure is intended for async use and avoids lifetime headaches. You will see this pattern in tokio::spawn, futures::stream::unfold, and many other async APIs.

Walkthrough: lazy evaluation and ownership

When you write |x| async move { ... }, the compiler generates two things. First, a closure struct. It holds the captured variables. Second, a future struct. It holds the state machine for the async block.

Calling the closure creates the future. The future captures the variables from the closure. If you use move, the variables are moved into the closure, and then moved into the future when the closure is called. The future owns everything it needs.

The future does not run until you poll it. Polling drives the state machine forward. If the future yields, it returns Pending. The executor puts it aside and polls other futures. When the yield completes, the executor polls the future again. The state machine resumes.

This lazy behavior is crucial. You can create a future without running it. You can store it, pass it, or drop it. The work only happens when the future is polled. This allows async code to be composed and scheduled efficiently.

The closure is just a factory for futures. It sets up the initial state. The future does the work. Understanding this separation helps you debug async code. If you see a borrow checker error, check whether the future is holding a reference that dies too soon. If you see a type error, check whether the closure returns the right kind of future.

Realistic example: generic async handlers

Real code often needs to accept async callbacks. You cannot write a function that takes an async |x| { ... } directly. You write a generic function that takes a closure returning a future.

use std::future::Future;
use std::pin::Pin;

// A function that accepts an async handler.
// F is the closure type.
// Fut is the future type returned by the closure.
async fn run_with_handler<F, Fut>(handler: F)
where
    F: FnOnce() -> Fut,
    Fut: Future<Output = ()>,
{
    // Call the closure to get the future.
    let fut = handler();

    // Await the future.
    fut.await;
}

async fn main() {
    // Pass a closure that returns a future.
    run_with_handler(|| async move {
        println!("Handler running");
    }).await;
}

The generic bounds look verbose. F: FnOnce() -> Fut says the closure takes no arguments and returns a Fut. Fut: Future<Output = ()> says the future resolves to unit. This pattern is standard in async Rust. You will see it in libraries like tokio and futures.

The FnOnce bound is common. Many async handlers run once. If you need to call the handler multiple times, use Fn or FnMut. The future type must match. If the handler captures mutable state, you need FnMut and the future must handle interior mutability.

Convention aside: when writing these generics, name the future type Fut or Future. It makes the bounds readable. Avoid single-letter names like F for the future, as F is often used for the function/closure type.

Pitfalls: lifetimes, Send, and the borrow checker

Async closures trip up developers on three fronts. Lifetimes, Send bounds, and type inference.

Lifetimes are the most common issue. If you write |x| async { println!("{}", x) } without move, the closure borrows x. The future holds a reference to x. The future must not outlive x. If you pass the future to another thread or store it, the borrow checker rejects you. You get E0597 (borrowed value does not live long enough). The fix is async move. It moves x into the future. The future owns x. The lifetime constraint disappears.

fn bad_example() {
    let data = String::from("hello");
    // This closure borrows data.
    // The future holds a reference.
    let handler = || async {
        println!("{}", data);
    };

    // If you try to spawn this, it fails.
    // The future is not Send because it borrows stack data.
    // tokio::spawn(handler()); // Error
}

Send bounds are the second issue. Many async executors require futures to be Send. A future is Send if all captured data is Send. If your closure captures a Rc or a raw pointer, the future is not Send. You get E0277 (trait bound not satisfied). The error message points to the closure or the future. The fix is to use Arc instead of Rc, or to restructure the code to avoid non-Send types.

Type inference is the third issue. Closures have anonymous types. When you pass an async closure to a generic function, the compiler must infer the closure type and the future type. Sometimes it gets stuck. You may need to add type annotations.

// The compiler might struggle to infer Fut.
// Adding a type annotation helps.
let handler: fn() -> impl Future<Output = ()> = || async {
    println!("Work");
};

Using fn pointer forces the closure to be coerced to a function pointer. This works for simple closures that capture nothing. If the closure captures variables, you cannot use fn. You must use the generic pattern or Box<dyn Future>.

Pitfall directive: if the borrow checker complains about lifetimes in an async closure, add move. If it complains about Send, check your captured types for Rc or references. If inference fails, annotate the future type.

Decision: when to use what

Rust offers several ways to handle async callbacks. Choose based on your needs.

Use |x| async move { ... } when you need to pass async behavior as a callback. This is the standard pattern for stable Rust. It works with tokio::spawn, futures::stream, and generic async functions. The closure returns a future. The future owns its data.

Use async fn when you can define a named function. Named functions are easier to read and debug. The compiler handles the future type automatically. You avoid the type complexity of closures. If the logic is substantial, prefer async fn over a closure.

Use async-trait when you need trait objects with async methods. Trait objects require a concrete type for the future. async-trait wraps the future in a Box. It lets you write async fn in trait definitions. It adds a small allocation overhead but enables dynamic dispatch.

Reach for nightly async_closure only when you are testing compiler features. The feature is unstable. The syntax and semantics may change. Do not use it in production code. The stable pattern works today.

Trust the closure-returning-future pattern. It is verbose but reliable. It scales to complex async systems. Master the pattern and you will write robust async Rust.

Where to go next