Async/Await & Futures

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0, the 2024 edition, tokio 1.53 and futures 0.3, checked on 2026-10-06. Async Rust lets one thread make progress on many tasks that spend most of their time waiting — for a network reply, a database row, a timer. Instead of blocking a thread per task, an async function returns a future that an executor polls when there is something to do. The language provides async/.await and the Future trait; the executor comes from a library, usually tokio (see Async Runtimes & Tokio). The axum section builds entirely on this model.

Why async

Threads are a fine way to do several things at once, but each OS thread costs memory (a stack of typically 2-8 MiB reserved) and a context switch to schedule. A server holding ten thousand idle connections cannot afford ten thousand threads. Async tasks are small state machines — often a few hundred bytes — and switching between them is a function call. Use async for I/O-bound concurrency; for CPU-bound parallelism, threads or Rayon remain the right tool (see Data Parallelism with Rayon).

The Future trait

A future is a value that will eventually produce a result. Its trait is small:

use std::pin::Pin;
use std::task::{Context, Poll};

pub trait FutureLike {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
// enum Poll<T> { Ready(T), Pending }
  • poll asks the future to make progress. It returns Poll::Ready(value) when done, or Poll::Pending if it must wait.

  • Before returning Pending, the future arranges for the Waker in cx to be called when it can make progress (a socket became readable, a timer fired). The executor then polls it again.

  • self is Pin<&mut Self> because futures created by async may hold references into themselves; see Pin & Unpin.

sequenceDiagram participant E as Executor participant F as Future (fetch book) participant R as Reactor (I/O driver) E->>F: poll(cx) F->>R: register interest in socket, store cx.waker() F-->>E: Poll::Pending Note over E: runs other tasks meanwhile R-->>R: socket becomes readable R->>E: waker.wake() E->>F: poll(cx) again F-->>E: Poll::Ready(book)

A hand-written future makes the protocol concrete. This one completes after being polled three times, waking itself each time (real futures wake when an external event happens):

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct CountDown(u8);

impl Future for CountDown {
    type Output = &'static str;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if self.0 == 0 {
            Poll::Ready("lift-off")
        } else {
            println!("polled, {} to go", self.0);
            self.0 -= 1;
            cx.waker().wake_by_ref(); // ask to be polled again
            Poll::Pending
        }
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    println!("{}", CountDown(3).await);
}

Futures are lazy

Calling an async fn does not run its body. It returns a future that does nothing until it is .awaited (or spawned on an executor). Forgetting the .await is the classic beginner bug, and the compiler warns: "unused implementer of Future that must be used — futures do nothing unless you .await or poll them".

async fn notify(member: &str) -> bool {
    println!("notifying {member}");
    true
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let pending = notify("alice");  // nothing printed yet: just a future
    println!("before await");
    let sent = pending.await;       // now the body runs
    println!("sent = {sent}");
}

async fn, async blocks and .await

  • async fn f(..) -> T is sugar for a function returning impl Future<Output = T>; the future captures the arguments (including references, so it borrows them).

  • An async { ... } block is an expression whose value is a future; async move { ... } takes ownership of the variables it uses, which is needed when the future must be 'static (for tokio::spawn).

  • .await can only be used inside async code. It polls the future and, if it is pending, yields control to the executor; the enclosing future resumes at that point later.

The compiler lowers each async body into an enum-like state machine with one state per .await point, holding the local variables that live across that point. Its size is the size of the largest state, which is why huge local arrays held across an .await make futures large.

use std::time::Duration;

async fn fetch_title(isbn: &str) -> Option<String> {
    tokio::time::sleep(Duration::from_millis(20)).await;   // simulated network call
    (isbn == "978").then(|| "Dune".to_string())
}

async fn fetch_rating(isbn: &str) -> u8 {
    tokio::time::sleep(Duration::from_millis(20)).await;
    if isbn == "978" { 5 } else { 0 }
}

#[tokio::main]
async fn main() {
    let isbn = String::from("978");
    let summary = async {                                   // async block borrowing `isbn`
        let title = fetch_title(&isbn).await?;              // ? works inside async blocks returning Option
        Some(format!("{title}: {} stars", fetch_rating(&isbn).await))
    };
    println!("{:?}", summary.await);

    let handle = tokio::spawn(async move {                  // spawned tasks need 'static: move
        fetch_rating(&isbn).await
    });
    println!("{}", handle.await.unwrap());
}

Concurrency: join! and select!

Awaiting futures one after another runs them sequentially. To run them concurrently on the same task, combine them:

  • tokio::join!(a, b, ...) (or futures::join!) polls all futures until every one completes and returns all results; try_join! stops at the first Err.

  • tokio::select! waits for the first of several futures to complete, then drops the others.

  • For a dynamic number of futures, use futures::future::join_all, FuturesUnordered, or tokio’s JoinSet (which spawns them as separate tasks).

use std::time::{Duration, Instant};
use tokio::time::sleep;

async fn lookup(source: &'static str, ms: u64) -> &'static str {
    sleep(Duration::from_millis(ms)).await;
    source
}

#[tokio::main]
async fn main() {
    let start = Instant::now();
    let a = lookup("catalog", 100).await;
    let b = lookup("reviews", 100).await;
    println!("sequential: {a}, {b} in ~{} ms", start.elapsed().as_millis() / 100 * 100); // ~200 ms

    let start = Instant::now();
    let (a, b) = tokio::join!(lookup("catalog", 100), lookup("reviews", 100));
    println!("joined: {a}, {b} in ~{} ms", start.elapsed().as_millis() / 100 * 100);      // ~100 ms

    let winner = tokio::select! {
        r = lookup("primary", 300) => r,
        r = lookup("replica", 50) => r,
        _ = sleep(Duration::from_millis(200)) => "timeout",
    };
    println!("first to answer: {winner}");                                              // replica
}

join! and select! give concurrency within one task: the futures share a thread. To use several CPU cores, spawn separate tasks with tokio::spawn on a multi-threaded runtime.

Cancellation

A future that is dropped before completion is cancelled: it is never polled again, and its locals are dropped at whatever .await it was suspended on. Cancellation is how select! discards the losers, how tokio::time::timeout works, and what happens when an HTTP client disconnects while axum is running a handler.

The consequence is cancel safety: code between two .await points always runs to completion, but code across an .await may stop halfway. A function that reads part of a message into a local buffer and then awaits more data loses that partial message if cancelled. Design for it: keep partial state in a struct that outlives the future, use cancel-safe APIs in select! loops (tokio documents which methods are), and use RAII guards for cleanup that must happen.

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

struct Cleanup(&'static str);
impl Drop for Cleanup {
    fn drop(&mut self) {
        println!("cleanup: {}", self.0); // runs even when the future is cancelled
    }
}

async fn slow_import() -> u32 {
    let _guard = Cleanup("release import lock");
    sleep(Duration::from_secs(10)).await; // cancelled while suspended here
    println!("never printed");
    42
}

#[tokio::main]
async fn main() {
    match timeout(Duration::from_millis(50), slow_import()).await {
        Ok(n) => println!("imported {n}"),
        Err(_) => println!("timed out: the import future was dropped"),
    }
}

Streams

A stream is the async counterpart of an iterator: it yields many values over time (poll_next returns Poll<Option<Item>>). The trait is not yet in the standard library; the ecosystem uses futures::Stream with StreamExt adapters (map, filter, buffer_unordered, next), and tokio-stream for tokio types. SSE and WebSocket handlers in axum consume and produce streams.

use futures::stream::{self, StreamExt};

async fn rating_for(isbn: u32) -> u32 {
    isbn % 5 + 1
}

#[tokio::main]
async fn main() {
    let ratings: Vec<u32> = stream::iter([978u32, 979, 980, 981])
        .map(rating_for)          // a stream of futures
        .buffer_unordered(2)      // run up to two concurrently
        .collect()
        .await;
    println!("{} ratings", ratings.len());

    let mut evens = stream::iter(1..=6).filter(|n| futures::future::ready(n % 2 == 0));
    while let Some(n) = evens.next().await {
        print!("{n} ");
    }
    println!();
}

async fn in traits, async closures and recursion

  • async fn in traits (1.75): trait methods can be async fn. Callers cannot require the returned future to be Send, which matters for generic code that spawns it; declare the method as fn get(&self) -> impl Future<Output = T> + Send when that is needed. Such traits are not dyn-compatible; see Trait Objects & dyn Compatibility.

  • async closures (1.85): async |x| { ... } with the AsyncFn, AsyncFnMut and AsyncFnOnce traits; see Closures & Fn Traits.

  • recursion: an async fn cannot call itself directly, because its state machine would contain itself (error E0733, "recursion in an async fn requires boxing"). Box the recursive call: Box::pin(walk(child)).await.

#[derive(Debug)]
struct Category {
    name: String,
    children: Vec<Category>,
}

async fn count(cat: &Category) -> usize {
    let mut n = 1;
    for child in &cat.children {
        n += Box::pin(count(child)).await; // boxing breaks the infinitely sized future
    }
    n
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let tree = Category {
        name: "Fiction".into(),
        children: vec![Category { name: "Sci-fi".into(), children: vec![] }, Category { name: "Classics".into(), children: vec![] }],
    };
    println!("{} has {} categories", tree.name, count(&tree).await);
}

Future and IntoFuture in the 2024 prelude

The 2024 edition adds Future and IntoFuture to the prelude, so implementing or bounding on them no longer needs an import. IntoFuture (1.64) is what .await actually calls, which lets builder types be awaited directly — reqwest’s client.get(url).send() and sqlx’s query builders use it.

The same idea in other languages

C#, JavaScript, Python and Swift have async/await with eager tasks scheduled by a built-in runtime; Rust’s futures are lazy, the runtime is a library, and futures are zero-cost state machines rather than heap-allocated objects. Java reached for the opposite design with virtual threads. Compare C#: Async & Await, JavaScript: Async JavaScript, Python: Concurrency & Async, Swift: Async/Await & Tasks, Java: Virtual Threads, Java: High-Level Concurrency, C++: Coroutines, C++: Async, Futures, and Atomics and PHP: Fibers & Asynchronous PHP.

Common errors

Error Cause and fix

E0728 await is only allowed inside async functions and blocks

make the caller async, or block on the future from synchronous code with the runtime’s block_on

"future cannot be sent between threads safely" (E0277)

a non-Send value (Rc, std::sync::MutexGuard, RefCell borrow) is alive across an .await; drop it before the .await or use Arc/tokio::sync::Mutex

"unused implementer of Future that must be used" (warning)

a missing .await

E0733 recursion in an async fn requires boxing

Box::pin the recursive call

E0373 async block may outlive the current function

add move to the async block passed to spawn

Where the books are outdated

Gangappa & Rangaraj (Mastering Distributed Observability in Rust, chapter 2) and Yoon (Beginning Axum, chapter 1) introduce async with tokio; both predate async closures (1.85) and the 2024 prelude additions. Code that uses the async-trait macro for static dispatch can switch to native async fn in traits (1.75).