How to Write a Custom Future in Rust

Create a custom Future in Rust by wrapping asynchronous logic in an `async` block or function and using `.await` to suspend execution.

When sequential code needs to pause

You're building a web server. A request arrives. You need to fetch user data from a database, transform it, and send a response. The database query takes 50 milliseconds. If you block the thread for those 50 milliseconds, your server can't handle any other requests. You need a way to pause the work, let the thread handle other requests, and resume exactly where you left off when the data arrives.

That's what a custom Future gives you. You write code that looks sequential, but the compiler transforms it into a state machine that can suspend and resume. You don't manage threads or callbacks. You define the logic, and the executor drives the state machine.

The Future trait is a contract, not a thread

A Future is a value that represents work that might not be done yet. It's a promise of a result. The Future trait has one method: poll. The executor calls poll to check if the future is ready. The future returns Poll::Ready(value) when it's done, or Poll::Pending when it needs to wait.

When the future returns Pending, it registers a Waker with the executor. The Waker is a callback that tells the executor to call poll again when the underlying event happens. If you're waiting for a network read, the future tells the OS to wake up when data arrives. The OS triggers the waker, the executor calls poll, and the future resumes.

The future does nothing until polled. It's lazy. The executor owns the loop. You provide the state transitions.

async blocks are compiler-generated state machines

Writing a Future by hand is tedious. You have to manage the state enum, the poll method, the waker registration, and Pin for self-referential pointers. Rust provides async blocks and async fn as syntactic sugar. The compiler transforms them into a struct that implements Future.

The compiler generates an enum representing the current step in your code. Each await point becomes a variant in the enum. The poll method matches on the enum, runs code until the next await, and saves the state. When the future resumes, it jumps back to the saved state.

This transformation is invisible to you. You write sequential code. The compiler handles the state machine. You get the safety of Rust's type system and the efficiency of a hand-written state machine.

Minimal example: an async block

An async block creates a Future inline. You can pass it to functions, store it in variables, or combine it with other futures. The block captures variables from the surrounding scope and suspends at await points.

use std::time::Duration;
use trpl::channel;
use trpl::sleep;

fn main() {
    // block_on runs a future to completion on the current thread.
    // It's a simple executor for learning.
    trpl::block_on(async {
        // Create a channel for sending strings between tasks.
        let (tx, mut rx) = channel();

        // async move captures variables by value.
        // This future owns tx and will send messages.
        let tx_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                // send returns a future.
                // await suspends this future until the send completes.
                tx.send(val).unwrap();
                
                // sleep returns a future that resolves after the duration.
                // await suspends here, allowing the executor to run other futures.
                sleep(Duration::from_millis(500)).await;
            }
        };

        // async block without move captures by reference.
        // This future borrows rx to receive messages.
        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        // join runs both futures concurrently.
        // It returns a tuple of results when both complete.
        trpl::join(tx_fut, rx_fut).await;
    });
}

The async move block takes ownership of tx. The async block borrows rx. The join combinator polls both futures until they're both ready. The executor handles the concurrency. You just wrote a custom future that sends and receives messages.

Convention aside: use async move when the future needs to own its data. Use async without move when you can borrow. async move is safer because the future doesn't depend on external lifetimes. It can be moved across threads or stored longer.

How the state machine works under the hood

The compiler generates a struct for each async block. The struct contains the captured variables and an enum for the state. The enum has a variant for each await point, plus a starting state and a completed state.

When poll is called, the method matches on the state enum. It runs code until the next await. If the await returns Ready, the code continues. If it returns Pending, the method saves the current state and returns Pending. The waker is registered during the await.

The state machine is efficient. It allocates once. It reuses the same memory for all states. The compiler optimizes the transitions. You get the performance of a hand-written state machine without the boilerplate.

The Pin type ensures the future isn't moved after it's polled. Futures can contain self-referential pointers. If you move a future, those pointers become invalid. Pin prevents moving. The executor pins the future before polling. You rarely interact with Pin directly. The compiler handles it for async blocks.

Realistic example: retry logic with async fn

In real code, you often need to compose futures into complex workflows. An async fn is the standard way to define a reusable future. It returns an opaque future type that the compiler generates.

use std::time::Duration;
use std::error::Error;
use trpl::sleep;

/// Fetches data with retry logic.
/// Retries up to max_retries times on failure.
async fn fetch_with_retry(url: &str, max_retries: u32) -> Result<String, Box<dyn Error>> {
    let mut attempts = 0;
    
    loop {
        // Simulate a fetch that might fail.
        // In real code, this would be a network request.
        match fetch(url).await {
            Ok(data) => return Ok(data),
            Err(e) if attempts < max_retries => {
                attempts += 1;
                eprintln!("Attempt {} failed: {}. Retrying...", attempts, e);
                
                // Wait before retrying.
                // await suspends here, yielding to the executor.
                sleep(Duration::from_secs(1)).await;
            }
            Err(e) => return Err(e.into()),
        }
    }
}

// Mock fetch function for demonstration.
async fn fetch(_url: &str) -> Result<String, Box<dyn Error>> {
    Err("Network error".into())
}

fn main() {
    trpl::block_on(async {
        match fetch_with_retry("https://example.com", 3).await {
            Ok(data) => println!("Got data: {}", data),
            Err(e) => eprintln!("Failed: {}", e),
        }
    });
}

The async fn returns a future that captures url and max_retries. The loop inside the async function works normally. The compiler handles the state transitions across the await points. You can use control flow, loops, and error handling just like in synchronous code.

Convention aside: prefer async fn over fn -> impl Future. async fn is simpler and more readable. Use impl Future when you need to return a future from a function but can't use async fn due to complex trait bounds or generic parameters. async fn is the standard for 99% of cases.

Pitfalls: blocking, Send, and borrowing

Async code has specific pitfalls. Blocking inside an async function stops the executor thread. If you call a blocking function, the executor can't run other futures. The entire application stalls.

Never call blocking functions inside async code. If you must, spawn a thread. Use spawn_blocking in tokio or thread::spawn to run blocking work on a separate thread pool. The async function should only contain non-blocking operations.

Futures must be Send to run on multi-threaded executors. If a future captures a non-Send value, it can't be moved across threads. The compiler rejects this with E0277 (the trait bound Future: Send is not satisfied). Ensure all captured values are Send. Use Arc for shared data across threads.

Borrowing across await points is tricky. The future might be moved or suspended. If you hold a reference across an await, the reference might become invalid. The compiler checks this. You'll get E0382 (use of moved value) or lifetime errors if you try to use a borrowed value after an await where it was moved. Clone the value or restructure the code to avoid holding references across await.

The future is just data. The executor drives it. Treat the future as a state machine that can be paused and resumed at any await. Design your code with that model in mind.

Decision: choosing your future strategy

Use async fn when you need a function that returns a future and want the compiler to handle the return type. It's the standard for defining async logic.

Use async { ... } when you need to create a future inline, like passing it to a function or storing it in a variable. It's useful for composing futures or capturing local state.

Use impl Future when you need to return a future from a function but can't use async fn due to complex trait bounds or generic parameters. It gives you flexibility in the return type.

Use a manual Future implementation when you are wrapping a callback-based API or building a low-level abstraction where the state machine approach doesn't fit. Reach for the manual implementation only when async isn't enough.

Reach for async fn first. It's the standard. The compiler generates efficient state machines. You get safety and ergonomics without boilerplate.

Where to go next