Skip to editor content
learningrust.orglesson 23 of 26

Async/Await

Asynchronous programming lets your program handle multiple tasks concurrently without blocking on slow operations like network requests, file I/O, or timers. Rust's async/await syntax makes this feel as natural as writing sequential code while giving you full control over performance and memory.

In this lesson you will learn how Rust's Future trait works under the hood, how to write and compose async functions, how to run tasks concurrently, and how to handle errors in async contexts.

What Is a Future?

In Rust, an async operation is represented by a Future — a value that will produce a result at some point. A Future is lazy: it does nothing until something drives it to completion. That "something" is an async runtime.

When you write async fn, the compiler transforms your function body into a state machine that implements the Future trait. The .await keyword suspends the current task and yields control back to the runtime until the future is ready, rather than blocking the entire thread.

async fn compute_answer() -> u32 {
    // This async function returns a Future<Output = u32>
    // It can be awaited by the caller
    42
}

#[tokio::main]
async fn main() {
    // .await drives the future to completion
    let answer = compute_answer().await;
    println!("The answer is: {}", answer);
}

Writing Async Functions

Any function can be made asynchronous by adding the async keyword before fn. The return type becomes a Future implicitly, and you use .await inside async functions to pause execution until another future resolves.

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

async fn fetch_user(id: u32) -> String {
    // Simulate a network round-trip without blocking the thread
    sleep(Duration::from_millis(50)).await;
    format!("User #{id}")
}

async fn fetch_profile(user: &str) -> String {
    sleep(Duration::from_millis(30)).await;
    format!("Profile for {user}: senior rustacean")
}

#[tokio::main]
async fn main() {
    let user = fetch_user(7).await;
    println!("Fetched: {user}");

    let profile = fetch_profile(&user).await;
    println!("Fetched: {profile}");
}

The two calls above are sequential — each waits for the previous one before starting. This is correct when the second call depends on the first, but unnecessary when tasks are independent.

Running Tasks Concurrently with tokio::join!

When multiple futures are independent, you can run them concurrently using tokio::join!. This starts all futures at the same time and waits until all of them complete, which is faster than awaiting each one in sequence.

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

async fn fetch_posts() -> Vec<String> {
    sleep(Duration::from_millis(100)).await;
    vec!["Post A".to_string(), "Post B".to_string()]
}

async fn fetch_comments() -> Vec<String> {
    sleep(Duration::from_millis(80)).await;
    vec!["Comment 1".to_string(), "Comment 2".to_string()]
}

async fn fetch_likes() -> u32 {
    sleep(Duration::from_millis(60)).await;
    42
}

#[tokio::main]
async fn main() {
    // All three run concurrently — total wait is ~100ms, not 240ms
    let (posts, comments, likes) = tokio::join!(
        fetch_posts(),
        fetch_comments(),
        fetch_likes(),
    );

    println!("Posts: {:?}", posts);
    println!("Comments: {:?}", comments);
    println!("Likes: {}", likes);
}

For cases where you want to spawn truly independent background tasks that run on the thread pool, use tokio::spawn. Unlike join!, spawned tasks can run even if you do not immediately await them.

Error Handling in Async Code

Async functions work naturally with Result. You can use the ? operator inside async functions exactly as you would in synchronous code, and propagate errors up the call chain.

use std::fmt;

#[derive(Debug)]
enum AppError {
    NotFound(u32),
    Unauthorized,
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::NotFound(id) => write!(f, "Resource {id} not found"),
            AppError::Unauthorized => write!(f, "Access denied"),
        }
    }
}

async fn load_resource(id: u32, token: &str) -> Result<String, AppError> {
    if token.is_empty() {
        return Err(AppError::Unauthorized);
    }
    if id == 0 {
        return Err(AppError::NotFound(id));
    }
    Ok(format!("Resource #{id}"))
}

async fn handle_request(id: u32, token: &str) -> Result<(), AppError> {
    // The ? operator works inside async functions
    let resource = load_resource(id, token).await?;
    println!("Loaded: {resource}");
    Ok(())
}

#[tokio::main]
async fn main() {
    let cases = vec![
        (1, "valid-token"),
        (0, "valid-token"),
        (5, ""),
    ];

    for (id, token) in cases {
        match handle_request(id, token).await {
            Ok(_) => {}
            Err(e) => println!("Error: {e}"),
        }
    }
}

Spawning Background Tasks

tokio::spawn lets you fire off a task and continue without waiting for it. The spawned task runs concurrently and returns a JoinHandle you can await later to collect the result or detect panics.

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

async fn background_sync(label: &'static str) -> String {
    sleep(Duration::from_millis(50)).await;
    format!("{label}: sync complete")
}

#[tokio::main]
async fn main() {
    // Spawn two tasks that run in parallel
    let handle_a = tokio::spawn(background_sync("cache"));
    let handle_b = tokio::spawn(background_sync("index"));

    // Do other work here while the tasks run...
    println!("Tasks are running in the background");

    // Collect results when needed
    let result_a = handle_a.await.expect("Task A panicked");
    let result_b = handle_b.await.expect("Task B panicked");

    println!("{result_a}");
    println!("{result_b}");
}

Try It Yourself

Build a small async pipeline that fetches multiple data sources concurrently, filters the results, and reports a summary. Modify it to introduce an error path and handle it gracefully.

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

#[derive(Debug)]
struct Record {
    id: u32,
    value: i64,
}

async fn fetch_batch(start: u32, count: u32) -> Vec<Record> {
    sleep(Duration::from_millis(40)).await;
    (start..start + count)
        .map(|id| Record { id, value: (id as i64) * 10 - 50 })
        .collect()
}

async fn process(records: Vec<Record>) -> (usize, i64) {
    let positive: Vec<_> = records.into_iter().filter(|r| r.value > 0).collect();
    let total: i64 = positive.iter().map(|r| r.value).sum();
    (positive.len(), total)
}

#[tokio::main]
async fn main() {
    // Fetch three batches concurrently
    let (batch_a, batch_b, batch_c) = tokio::join!(
        fetch_batch(0, 5),
        fetch_batch(5, 5),
        fetch_batch(10, 5),
    );

    let all_records: Vec<Record> = batch_a
        .into_iter()
        .chain(batch_b)
        .chain(batch_c)
        .collect();

    println!("Fetched {} records total", all_records.len());

    let (count, total) = process(all_records).await;
    println!("Positive records: {count}, sum: {total}");
}

Try extending this by: wrapping fetch_batch to return Result<Vec<Record>, String> and simulating a failure for one of the batches, then collecting only successful batches.

Key Takeaways

  • async fn transforms a function into one that returns a Future, which is lazy until driven by a runtime
  • .await suspends the current task and yields to the runtime without blocking the OS thread
  • Use tokio::join! to run independent futures concurrently and collect all results
  • Use tokio::spawn to fire-and-forget background tasks that return a JoinHandle
  • The ? operator works inside async functions, making error propagation idiomatic
  • Async code in Rust is zero-cost: you pay only for the concurrency you actually use
  • An async runtime (like Tokio) is required; the standard library provides the Future trait but no executor

Pro Tip: Avoid holding std::sync::MutexGuard (or any non-Send type) across an .await point. The compiler will catch this, but the error message can be cryptic. Use tokio::sync::Mutex when you need a lock that spans await points — it releases the lock correctly when the task is suspended, preventing deadlocks.

Next Steps

Now that you understand async programming, we'll learn about serde — Rust's powerful serialization framework for working with JSON, TOML, and other data formats.

Next lesson

Serde and JSON

Learn how to serialize and deserialize data structures to and from JSON using the serde and serde_json crates

25 min