Async Rust

Async Rust lets you write concurrent code that handles thousands of connections without threads. Futures are lazy, zero-cost, and composable - but the ecosystem has a learning curve.

Futures and async/await

A Future is a value that will resolve later. async fn returns a future. .await suspends execution until the future completes.

async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
    let response = reqwest::get(url).await?;
    let body = response.text().await?;
    Ok(body)
}

Gotcha: Futures are lazy in Rust. They do nothing until polled. Just calling fetch_data("...") returns a future but doesn’t start the request. You must .await it or spawn it on a runtime.

Tokio: The Standard Runtime

Rust’s standard library defines the Future trait but doesn’t include a runtime. Tokio is the de facto choice.

[dependencies]
tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
    let result = fetch_data("https://httpbin.org/get").await;
    match result {
        Ok(body) => println!("{body}"),
        Err(e) => eprintln!("Error: {e}"),
    }
}

#[tokio::main] is a macro that sets up the multi-threaded runtime. It expands to:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async { /* your code */ })
}

Spawning Tasks

tokio::spawn - fire-and-forget concurrent work

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

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        "task complete"
    });

    println!("Doing other work...");
    let result = handle.await.unwrap(); // wait for the spawned task
    println!("{result}");
}

JoinSet - manage multiple spawned tasks

use tokio::task::JoinSet;

async fn fetch_all(urls: Vec<String>) -> Vec<String> {
    let mut set = JoinSet::new();

    for url in urls {
        set.spawn(async move {
            reqwest::get(&url).await?.text().await
        });
    }

    let mut results = Vec::new();
    while let Some(res) = set.join_next().await {
        match res {
            Ok(Ok(body)) => results.push(body),
            Ok(Err(e)) => eprintln!("Request failed: {e}"),
            Err(e) => eprintln!("Task panicked: {e}"),
        }
    }

    results
}

Tip: JoinSet is the modern way to manage groups of tasks. It replaces the old pattern of collecting Vec<JoinHandle> and awaiting each one.

Concurrent Execution

tokio::join! - run futures concurrently, wait for all

let (users, posts, comments) = tokio::join!(
    fetch_users(),
    fetch_posts(),
    fetch_comments(),
);

tokio::select! - race futures, take the first to complete

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

async fn fetch_with_timeout() -> Result<String, &'static str> {
    tokio::select! {
        result = fetch_data("https://slow-api.example.com") => {
            result.map_err(|_| "request failed")
        }
        _ = sleep(Duration::from_secs(5)) => {
            Err("request timed out")
        }
    }
}

Gotcha: select! cancels the other futures when one completes. Make sure your futures handle cancellation safely - drop any partially-acquired resources.

Async Closures (Rust 1.88+)

Async closures are now stable. They capture variables properly and return futures.

let urls = vec!["https://a.com", "https://b.com"];

// Async closure - captures `client` by reference
let client = reqwest::Client::new();
let fetch = async |url: &str| -> Result<String, reqwest::Error> {
    client.get(url).send().await?.text().await
};

for url in &urls {
    let body = fetch(url).await?;
    println!("{}: {} bytes", url, body.len());
}

Before Rust 1.88, you had to use workarounds with move closures returning async blocks. Async closures simplify this significantly.

Async Functions in Traits (Native)

Async functions in traits work natively without the async-trait crate.

trait DataStore {
    async fn get(&self, key: &str) -> Option<String>;
    async fn set(&self, key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>>;
}

struct RedisStore { /* ... */ }

impl DataStore for RedisStore {
    async fn get(&self, key: &str) -> Option<String> {
        // actual Redis lookup
        todo!()
    }

    async fn set(&self, key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> {
        // actual Redis write
        todo!()
    }
}

Tip: If you need trait objects (dyn DataStore), you’ll need the trait_variant crate or manual desugaring, since async methods aren’t directly object-safe.

Channels

Tokio provides async channels for task communication.

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(100); // buffer size 100

    // Producer
    let tx2 = tx.clone();
    tokio::spawn(async move {
        tx2.send("hello from task".to_string()).await.unwrap();
    });

    // Send from main
    tx.send("hello from main".to_string()).await.unwrap();
    drop(tx); // drop sender to close the channel

    // Consumer
    while let Some(msg) = rx.recv().await {
        println!("Received: {msg}");
    }
}
ChannelUse case
mpscMultiple producers, single consumer
oneshotSend exactly one value (request/response)
broadcastMultiple producers, multiple consumers (pub/sub)
watchSingle producer, multiple consumers (latest value)

When NOT to Use Async

Async Rust adds complexity. Use it when you need it, not by default.

Use asyncUse threads or sync
Many concurrent I/O operations (HTTP, DB, files)CPU-heavy computation
Web servers handling thousands of connectionsSimple CLI tools
Network clients making parallel requestsBatch processing with limited concurrency
Event-driven applicationsAnything where you have < 100 concurrent operations

Gotcha: Mixing blocking code with async is a common mistake. Never call std::thread::sleep or blocking I/O in an async context. Use tokio::task::spawn_blocking for blocking work inside an async runtime.

// Wrong: blocks the async executor
let data = std::fs::read_to_string("big-file.txt")?;

// Right: offload to a blocking thread
let data = tokio::task::spawn_blocking(|| {
    std::fs::read_to_string("big-file.txt")
}).await??;

// Or use Tokio's async file I/O
let data = tokio::fs::read_to_string("big-file.txt").await?;

Exercises

  1. Write an async function that fetches 3 URLs concurrently using tokio::join! and returns the total byte count
  2. Build a simple web scraper with JoinSet that fetches N pages in parallel with a concurrency limit (hint: limit JoinSet size)
  3. Use tokio::select! to implement a timeout wrapper: async fn with_timeout<T>(future: impl Future<Output = T>, secs: u64) -> Option<T>

Next: Pattern Matching | Ecosystem